Update dependencies

- uses newer version of go-ethereum required for go1.11
This commit is contained in:
Rob Mulholand
2018-09-13 16:14:35 -05:00
parent 939ead0c82
commit 560305f601
2356 changed files with 331656 additions and 128304 deletions
+7 -8
View File
@@ -12,28 +12,27 @@ The client's UI uses [React][React] with JSX syntax, which is validated by the [
As the dashboard depends on certain NPM packages (which are not included in the `go-ethereum` repo), these need to be installed first:
```
$ (cd dashboard/assets && npm install)
$ (cd dashboard/assets && ./node_modules/.bin/flow-typed install)
$ (cd dashboard/assets && yarn install && yarn flow)
```
Normally the dashboard assets are bundled into Geth via `go-bindata` to avoid external dependencies. Rebuilding Geth after each UI modification however is not feasible from a developer perspective. Instead, we can run `webpack` in watch mode to automatically rebundle the UI, and ask `geth` to use external assets to not rely on compiled resources:
Normally the dashboard assets are bundled into Geth via `go-bindata` to avoid external dependencies. Rebuilding Geth after each UI modification however is not feasible from a developer perspective. Instead, we can run `yarn dev` to watch for file system changes and refresh the browser automatically.
```
$ (cd dashboard/assets && ./node_modules/.bin/webpack --watch)
$ geth --dashboard --dashboard.assets=dashboard/assets --vmodule=dashboard=5
$ geth --dashboard --vmodule=dashboard=5
$ (cd dashboard/assets && yarn dev)
```
To bundle up the final UI into Geth, run `go generate`:
```
$ go generate ./dashboard
$ (cd dashboard && go generate)
```
### Static type checking
Since JavaScript doesn't provide type safety, [Flow][Flow] is used to check types. These are only useful during development, so at the end of the process Babel will strip them.
To take advantage of static type checking, your IDE needs to be prepared for it. In case of [Atom][Atom] a configuration guide can be found [here][Atom config]: Install the [Nuclide][Nuclide] package for Flow support, making sure it installs all of its support packages by enabling `Install Recommended Packages on Startup`, and set the path of the `flow-bin` which were installed previously by `npm`.
To take advantage of static type checking, your IDE needs to be prepared for it. In case of [Atom][Atom] a configuration guide can be found [here][Atom config]: Install the [Nuclide][Nuclide] package for Flow support, making sure it installs all of its support packages by enabling `Install Recommended Packages on Startup`, and set the path of the `flow-bin` which were installed previously by `yarn`.
For more IDE support install the `linter-eslint` package too, which finds the `.eslintrc` file, and provides real-time linting. Atom warns, that these two packages are incompatible, but they seem to work well together. For third-party library errors and auto-completion [flow-typed][flow-typed] is used.
@@ -41,7 +40,7 @@ For more IDE support install the `linter-eslint` package too, which finds the `.
[Webpack][Webpack] offers handy tools for visualizing the bundle's dependency tree and space usage.
* Generate the bundle's profile running `webpack --profile --json > stats.json`
* Generate the bundle's profile running `yarn stats`
* For the _dependency tree_ go to [Webpack Analyze][WA], and import `stats.json`
* For the _space usage_ go to [Webpack Visualizer][WV], and import `stats.json`
+11593 -9364
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -68,4 +68,4 @@ export const styles = {
light: {
color: 'rgba(255, 255, 255, 0.54)',
},
}
};
@@ -27,16 +27,17 @@ const styles = {
body: {
display: 'flex',
width: '100%',
height: '100%',
height: '92%',
},
};
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>
);
@@ -38,15 +38,15 @@ export const percentPlotter = <T>(text: string, mapper: (T => T) = multiplier(1)
};
// unit contains the units for the bytePlotter.
const unit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const unit = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'];
// simplifyBytes returns the simplified version of the given value followed by the unit.
const simplifyBytes = (x: number) => {
let i = 0;
for (; x > 1024 && i < 5; i++) {
for (; x > 1024 && i < 8; i++) {
x /= 1024;
}
return x.toFixed(2).toString().concat(' ', unit[i]);
return x.toFixed(2).toString().concat(' ', unit[i], 'B');
};
// bytePlotter renders a tooltip, which displays the payload as a byte value.
@@ -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,13 +76,20 @@ 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,
},
home: {
home: {},
chain: {},
txpool: {},
network: {},
system: {
activeMemory: [],
virtualMemory: [],
networkIngress: [],
@@ -91,14 +99,14 @@ const defaultContent: Content = {
diskRead: [],
diskWrite: [],
},
chain: {},
txpool: {},
network: {},
system: {},
logs: {
log: [],
logs: {
chunks: [],
endTop: false,
endBottom: true,
topChanged: 0,
bottomChanged: 0,
},
};
});
// updaters contains the state updater functions for each path of the state.
//
@@ -108,7 +116,11 @@ const updaters = {
version: replacer,
commit: replacer,
},
home: {
home: null,
chain: null,
txpool: null,
network: null,
system: {
activeMemory: appender(200),
virtualMemory: appender(200),
networkIngress: appender(200),
@@ -118,13 +130,7 @@ const updaters = {
diskRead: appender(200),
diskWrite: appender(200),
},
chain: null,
txpool: null,
network: null,
system: null,
logs: {
log: appender(200),
},
logs: logInserter(5),
};
// styles contains the constant styles of the component.
@@ -136,7 +142,7 @@ const styles = {
height: '100%',
zIndex: 1,
overflow: 'hidden',
}
},
};
// themeStyles returns the styles generated from the theme for 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,
};
}
@@ -178,9 +186,10 @@ class Dashboard extends Component<Props, State> {
// reconnect establishes a websocket connection with the server, listens for incoming messages
// and tries to reconnect on connection loss.
reconnect = () => {
const server = new WebSocket(`${((window.location.protocol === 'https:') ? 'wss://' : 'ws://') + window.location.host}/api`);
// 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);
@@ -191,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 => ({
@@ -217,7 +234,6 @@ class Dashboard extends Component<Props, State> {
return (
<div className={this.props.classes.dashboard} style={styles.dashboard}>
<Header
opened={this.state.sideBar}
switchSideBar={this.switchSideBar}
/>
<Body
@@ -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>
);
@@ -26,7 +26,17 @@ import {ResponsiveContainer, AreaChart, Area, Tooltip} from 'recharts';
import ChartRow from './ChartRow';
import CustomTooltip, {bytePlotter, bytePerSecPlotter, percentPlotter, multiplier} from './CustomTooltip';
import {styles as commonStyles} from '../common';
import type {Content} from '../types/content';
import type {General, System} from '../types/content';
const FOOTER_SYNC_ID = 'footerSyncId';
const CPU = 'cpu';
const MEMORY = 'memory';
const DISK = 'disk';
const TRAFFIC = 'traffic';
const TOP = 'Top';
const BOTTOM = 'Bottom';
// styles contains the constant styles of the component.
const styles = {
@@ -40,17 +50,16 @@ const styles = {
padding: 0,
},
doubleChartWrapper: {
height: '100%',
width: '99%',
paddingTop: 5,
height: '100%',
width: '99%',
},
};
// themeStyles returns the styles generated from the theme for the component.
const themeStyles: Object = (theme: Object) => ({
footer: {
backgroundColor: theme.palette.background.appBar,
color: theme.palette.getContrastText(theme.palette.background.appBar),
backgroundColor: theme.palette.grey[900],
color: theme.palette.getContrastText(theme.palette.grey[900]),
zIndex: theme.zIndex.appBar,
height: theme.spacing.unit * 10,
},
@@ -59,111 +68,108 @@ const themeStyles: Object = (theme: Object) => ({
export type Props = {
classes: Object, // injected by withStyles()
theme: Object,
content: Content,
general: General,
system: System,
shouldUpdate: Object,
};
// Footer renders the footer of the dashboard.
class Footer extends Component<Props> {
shouldComponentUpdate(nextProps) {
return typeof nextProps.shouldUpdate.home !== 'undefined';
return typeof nextProps.shouldUpdate.general !== 'undefined' || typeof nextProps.shouldUpdate.system !== 'undefined';
}
// info renders a label with the given values.
info = (about: string, value: ?string) => (value ? (
<Typography type='caption' color='inherit'>
<span style={commonStyles.light}>{about}</span> {value}
</Typography>
) : null);
// halfHeightChart renders an area chart with half of the height of its parent.
halfHeightChart = (chartProps, tooltip, areaProps) => (
<ResponsiveContainer width='100%' height='50%'>
<AreaChart {...chartProps} >
{!tooltip || (<Tooltip cursor={false} content={<CustomTooltip tooltip={tooltip} />} />)}
<Area isAnimationActive={false} type='monotone' {...areaProps} />
</AreaChart>
</ResponsiveContainer>
);
// doubleChart renders a pair of charts separated by the baseline.
doubleChart = (syncId, topChart, bottomChart) => {
const topKey = 'topKey';
const bottomKey = 'bottomKey';
const topDefault = topChart.default ? topChart.default : 0;
const bottomDefault = bottomChart.default ? bottomChart.default : 0;
const topTooltip = topChart.tooltip ? (
<Tooltip cursor={false} content={<CustomTooltip tooltip={topChart.tooltip} />} />
) : null;
const bottomTooltip = bottomChart.tooltip ? (
<Tooltip cursor={false} content={<CustomTooltip tooltip={bottomChart.tooltip} />} />
) : null;
doubleChart = (syncId, chartKey, topChart, bottomChart) => {
if (!Array.isArray(topChart.data) || !Array.isArray(bottomChart.data)) {
return null;
}
const topDefault = topChart.default || 0;
const bottomDefault = bottomChart.default || 0;
const topKey = `${chartKey}${TOP}`;
const bottomKey = `${chartKey}${BOTTOM}`;
const topColor = '#8884d8';
const bottomColor = '#82ca9d';
// Put the samples of the two charts into the same array in order to avoid problems
// at the synchronized area charts. If one of the two arrays doesn't have value at
// a given position, give it a 0 default value.
let data = [...topChart.data.map(({value}) => {
const d = {};
d[topKey] = value || topDefault;
return d;
})];
for (let i = 0; i < data.length && i < bottomChart.data.length; i++) {
// The value needs to be negative in order to plot it upside down.
const d = bottomChart.data[i];
data[i][bottomKey] = d && d.value ? -d.value : bottomDefault;
}
data = [...data, ...bottomChart.data.slice(data.length).map(({value}) => {
const d = {};
d[topKey] = topDefault;
d[bottomKey] = -value || bottomDefault;
return d;
})];
return (
<div style={styles.doubleChartWrapper}>
<ResponsiveContainer width='100%' height='50%'>
<AreaChart data={data} syncId={syncId} >
{topTooltip}
<Area type='monotone' dataKey={topKey} stroke={topColor} fill={topColor} />
</AreaChart>
</ResponsiveContainer>
<div style={{marginTop: -10, width: '100%', height: '50%'}}>
<ResponsiveContainer width='100%' height='100%'>
<AreaChart data={data} syncId={syncId} >
{bottomTooltip}
<Area type='monotone' dataKey={bottomKey} stroke={bottomColor} fill={bottomColor} />
</AreaChart>
</ResponsiveContainer>
</div>
{this.halfHeightChart(
{
syncId,
data: topChart.data.map(({value}) => ({[topKey]: value || topDefault})),
margin: {top: 5, right: 5, bottom: 0, left: 5},
},
topChart.tooltip,
{dataKey: topKey, stroke: topColor, fill: topColor},
)}
{this.halfHeightChart(
{
syncId,
data: bottomChart.data.map(({value}) => ({[bottomKey]: -value || -bottomDefault})),
margin: {top: 0, right: 5, bottom: 5, left: 5},
},
bottomChart.tooltip,
{dataKey: bottomKey, stroke: bottomColor, fill: bottomColor},
)}
</div>
);
}
};
render() {
const {content} = this.props;
const {general, home} = content;
const {general, system} = this.props;
return (
<Grid container className={this.props.classes.footer} direction='row' alignItems='center' style={styles.footer}>
<Grid item xs style={styles.chartRowWrapper}>
<ChartRow>
{this.doubleChart(
'all',
{data: home.processCPU, tooltip: percentPlotter('Process')},
{data: home.systemCPU, tooltip: percentPlotter('System', multiplier(-1))},
FOOTER_SYNC_ID,
CPU,
{data: system.processCPU, tooltip: percentPlotter('Process load')},
{data: system.systemCPU, tooltip: percentPlotter('System load', multiplier(-1))},
)}
{this.doubleChart(
'all',
{data: home.activeMemory, tooltip: bytePlotter('Active')},
{data: home.virtualMemory, tooltip: bytePlotter('Virtual', multiplier(-1))},
FOOTER_SYNC_ID,
MEMORY,
{data: system.activeMemory, tooltip: bytePlotter('Active memory')},
{data: system.virtualMemory, tooltip: bytePlotter('Virtual memory', multiplier(-1))},
)}
{this.doubleChart(
'all',
{data: home.diskRead, tooltip: bytePerSecPlotter('Disk Read')},
{data: home.diskWrite, tooltip: bytePerSecPlotter('Disk Write', multiplier(-1))},
FOOTER_SYNC_ID,
DISK,
{data: system.diskRead, tooltip: bytePerSecPlotter('Disk read')},
{data: system.diskWrite, tooltip: bytePerSecPlotter('Disk write', multiplier(-1))},
)}
{this.doubleChart(
'all',
{data: home.networkIngress, tooltip: bytePerSecPlotter('Download')},
{data: home.networkEgress, tooltip: bytePerSecPlotter('Upload', multiplier(-1))},
FOOTER_SYNC_ID,
TRAFFIC,
{data: system.networkIngress, tooltip: bytePerSecPlotter('Download')},
{data: system.networkEgress, tooltip: bytePerSecPlotter('Upload', multiplier(-1))},
)}
</ChartRow>
</Grid>
<Grid item >
{this.info('Geth', general.version)}
{this.info('Commit', general.commit ? general.commit.substring(0, 7) : null)}
<Typography type='caption' color='inherit'>
<span style={commonStyles.light}>Geth</span> {general.version}
</Typography>
{general.commit && (
<Typography type='caption' color='inherit'>
<span style={commonStyles.light}>{'Commit '}</span>
<a href={`https://github.com/ethereum/go-ethereum/commit/${general.commit}`} target='_blank' style={{color: 'inherit', textDecoration: 'none'}} >
{general.commit.substring(0, 8)}
</a>
</Typography>
)}
</Grid>
</Grid>
);
@@ -21,30 +21,26 @@ import React, {Component} from 'react';
import withStyles from 'material-ui/styles/withStyles';
import AppBar from 'material-ui/AppBar';
import Toolbar from 'material-ui/Toolbar';
import Transition from 'react-transition-group/Transition';
import IconButton from 'material-ui/IconButton';
import Icon from 'material-ui/Icon';
import MenuIcon from 'material-ui-icons/Menu';
import Typography from 'material-ui/Typography';
import ChevronLeftIcon from 'material-ui-icons/ChevronLeft';
import {DURATION} from '../common';
// styles contains the constant styles of the component.
const styles = {
arrow: {
default: {
transition: `transform ${DURATION}ms`,
},
transition: {
entered: {transform: 'rotate(180deg)'},
},
header: {
height: '8%',
},
toolbar: {
height: '100%',
},
};
// themeStyles returns the styles generated from the theme for the component.
const themeStyles = (theme: Object) => ({
header: {
backgroundColor: theme.palette.background.appBar,
color: theme.palette.getContrastText(theme.palette.background.appBar),
backgroundColor: theme.palette.grey[900],
color: theme.palette.getContrastText(theme.palette.grey[900]),
zIndex: theme.zIndex.appBar,
},
toolbar: {
@@ -53,42 +49,28 @@ const themeStyles = (theme: Object) => ({
},
title: {
paddingLeft: theme.spacing.unit,
fontSize: 3 * theme.spacing.unit,
},
});
export type Props = {
classes: Object, // injected by withStyles()
opened: boolean,
switchSideBar: () => void,
};
// Header renders the header of the dashboard.
class Header extends Component<Props> {
shouldComponentUpdate(nextProps) {
return nextProps.opened !== this.props.opened;
}
// arrow renders a button, which changes the sidebar's state.
arrow = (transitionState: string) => (
<IconButton onClick={this.props.switchSideBar}>
<ChevronLeftIcon
style={{
...styles.arrow.default,
...styles.arrow.transition[transitionState],
}}
/>
</IconButton>
);
render() {
const {classes, opened} = this.props;
const {classes} = this.props;
return (
<AppBar position='static' className={classes.header}>
<Toolbar className={classes.toolbar}>
<Transition mountOnEnter in={opened} timeout={{enter: DURATION}}>
{this.arrow}
</Transition>
<AppBar position='static' className={classes.header} style={styles.header}>
<Toolbar className={classes.toolbar} style={styles.toolbar}>
<IconButton onClick={this.props.switchSideBar}>
<Icon>
<MenuIcon />
</Icon>
</IconButton>
<Typography type='title' color='inherit' noWrap className={classes.title}>
Go Ethereum Dashboard
</Typography>
@@ -0,0 +1,326 @@
// @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 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<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&nbsp;';
color = '#4c8f0f';
break;
case 'warn':
lvl = 'WARN&nbsp;';
color = '#b79a22';
break;
case 'error':
case 'eror':
lvl = 'ERROR';
color = '#754b70';
break;
case 'crit':
lvl = 'CRIT&nbsp;';
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 += '&nbsp;'.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 = 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 = '&nbsp;'.repeat(padding - val.length);
}
content += ` <span style="color:${color}">${key}</span>=${val}${p}`;
}
content += '<br />';
});
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<Props, State> {
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 (
<div ref={(ref) => { this.content = ref; }}>
<div style={styles.waitMsg}>
{this.props.content.logs.endTop ? 'No more logs.' : 'Waiting for server...'}
</div>
<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>
{this.props.content.logs.endBottom || <div style={styles.waitMsg}>Waiting for server...</div>}
</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,14 +96,30 @@ 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
content={content}
general={content.general}
system={content.system}
shouldUpdate={shouldUpdate}
/>
</div>
@@ -41,10 +41,10 @@ const styles = {
// themeStyles returns the styles generated from the theme for the component.
const themeStyles = theme => ({
list: {
background: theme.palette.background.appBar,
background: theme.palette.grey[900],
},
listItem: {
minWidth: theme.spacing.unit * 3,
minWidth: theme.spacing.unit * 7,
},
icon: {
fontSize: theme.spacing.unit * 3,
@@ -14,6 +14,9 @@
::-webkit-scrollbar-thumb {
background: #212121;
}
::-webkit-scrollbar-corner {
background: transparent;
}
</style>
</head>
<body style="height: 100%; margin: 0">
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -13,13 +13,14 @@
"babel-runtime": "^6.26.0",
"classnames": "^2.2.5",
"css-loader": "^0.28.9",
"escape-html": "^1.0.3",
"eslint": "^4.16.0",
"eslint-config-airbnb": "^16.1.0",
"eslint-loader": "^1.9.0",
"eslint-loader": "^2.0.0",
"eslint-plugin-flowtype": "^2.41.0",
"eslint-plugin-import": "^2.8.0",
"eslint-plugin-jsx-a11y": "^6.0.3",
"eslint-plugin-react": "^7.5.1",
"eslint-plugin-flowtype": "^2.41.0",
"file-loader": "^1.1.6",
"flow-bin": "^0.63.1",
"flow-bin-loader": "^1.0.2",
@@ -35,6 +36,13 @@
"style-loader": "^0.19.1",
"url": "^0.11.0",
"url-loader": "^0.6.2",
"webpack": "^3.10.0"
"webpack": "^3.10.0",
"webpack-dev-server": "^2.11.1"
},
"scripts": {
"build": "NODE_ENV=production webpack",
"stats": "webpack --profile --json > stats.json",
"dev": "webpack-dev-server --port 8081",
"flow": "flow-typed install"
}
}
+50 -24
View File
@@ -18,37 +18,30 @@
export type Content = {
general: General,
home: Home,
chain: Chain,
txpool: TxPool,
home: Home,
chain: Chain,
txpool: TxPool,
network: Network,
system: System,
logs: Logs,
};
export type General = {
version: ?string,
commit: ?string,
};
export type Home = {
activeMemory: ChartEntries,
virtualMemory: ChartEntries,
networkIngress: ChartEntries,
networkEgress: ChartEntries,
processCPU: ChartEntries,
systemCPU: ChartEntries,
diskRead: ChartEntries,
diskWrite: ChartEntries,
system: System,
logs: Logs,
};
export type ChartEntries = Array<ChartEntry>;
export type ChartEntry = {
time: Date,
time: Date,
value: number,
};
export type General = {
version: ?string,
commit: ?string,
};
export type Home = {
/* TODO (kurkomisi) */
};
export type Chain = {
/* TODO (kurkomisi) */
};
@@ -62,9 +55,42 @@ export type Network = {
};
export type System = {
/* TODO (kurkomisi) */
activeMemory: ChartEntries,
virtualMemory: ChartEntries,
networkIngress: ChartEntries,
networkEgress: ChartEntries,
processCPU: ChartEntries,
systemCPU: ChartEntries,
diskRead: ChartEntries,
diskWrite: ChartEntries,
};
export type Record = {
t: string,
lvl: Object,
msg: string,
ctx: Array<string>
};
export type Chunk = {
content: string,
name: string,
};
export type Logs = {
log: Array<string>,
chunks: Array<Chunk>,
endTop: boolean,
endBottom: boolean,
topChanged: number,
bottomChanged: number,
};
export type LogsMessage = {
source: ?LogFile,
chunk: Array<Record>,
};
export type LogFile = {
name: string,
last: string,
};
@@ -32,6 +32,9 @@ module.exports = {
mangle: false,
beautify: true,
}),
new webpack.DefinePlugin({
PROD: process.env.NODE_ENV === 'production',
}),
],
module: {
rules: [
File diff suppressed because it is too large Load Diff
-4
View File
@@ -38,8 +38,4 @@ type Config struct {
// Refresh is the refresh rate of the data updates, the chartEntry will be collected this often.
Refresh time.Duration `toml:",omitempty"`
// Assets offers a possibility to manually set the dashboard website's location on the server side.
// It is useful for debugging, avoids the repeated generation of the binary.
Assets string `toml:",omitempty"`
}
+96 -107
View File
@@ -16,30 +16,31 @@
package dashboard
//go:generate npm --prefix ./assets install
//go:generate ./assets/node_modules/.bin/webpack --config ./assets/webpack.config.js --context ./assets
//go:generate go-bindata -nometadata -o assets.go -prefix assets -nocompress -pkg dashboard assets/dashboard.html assets/bundle.js
//go:generate yarn --cwd ./assets install
//go:generate yarn --cwd ./assets build
//go:generate go-bindata -nometadata -o assets.go -prefix assets -nocompress -pkg dashboard assets/index.html assets/bundle.js
//go:generate sh -c "sed 's#var _bundleJs#//nolint:misspell\\\n&#' assets.go > assets.go.tmp && mv assets.go.tmp assets.go"
//go:generate sh -c "sed 's#var _dashboardHtml#//nolint:misspell\\\n&#' assets.go > assets.go.tmp && mv assets.go.tmp assets.go"
//go:generate sh -c "sed 's#var _indexHtml#//nolint:misspell\\\n&#' assets.go > assets.go.tmp && mv assets.go.tmp assets.go"
//go:generate gofmt -w -s assets.go
import (
"fmt"
"io/ioutil"
"net"
"net/http"
"path/filepath"
"runtime"
"sync"
"sync/atomic"
"time"
"io"
"github.com/elastic/gosigar"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/metrics"
"github.com/ethereum/go-ethereum/p2p"
"github.com/ethereum/go-ethereum/params"
"github.com/ethereum/go-ethereum/rpc"
"github.com/mohae/deepcopy"
"golang.org/x/net/websocket"
)
@@ -62,10 +63,11 @@ type Dashboard struct {
listener net.Listener
conns map[uint32]*client // Currently live websocket connections
charts *HomeMessage
commit string
history *Message
lock sync.RWMutex // Lock protecting the dashboard's internals
logdir string
quit chan chan error // Channel used for graceful exit
wg sync.WaitGroup
}
@@ -73,30 +75,39 @@ type Dashboard struct {
// client represents active websocket connection with a remote browser.
type client struct {
conn *websocket.Conn // Particular live websocket connection
msg chan Message // Message queue for the update messages
msg chan *Message // Message queue for the update messages
logger log.Logger // Logger for the particular live websocket connection
}
// New creates a new dashboard instance with the given configuration.
func New(config *Config, commit string) (*Dashboard, error) {
func New(config *Config, commit string, logdir string) *Dashboard {
now := time.Now()
db := &Dashboard{
versionMeta := ""
if len(params.VersionMeta) > 0 {
versionMeta = fmt.Sprintf(" (%s)", params.VersionMeta)
}
return &Dashboard{
conns: make(map[uint32]*client),
config: config,
quit: make(chan chan error),
charts: &HomeMessage{
ActiveMemory: emptyChartEntries(now, activeMemorySampleLimit, config.Refresh),
VirtualMemory: emptyChartEntries(now, virtualMemorySampleLimit, config.Refresh),
NetworkIngress: emptyChartEntries(now, networkIngressSampleLimit, config.Refresh),
NetworkEgress: emptyChartEntries(now, networkEgressSampleLimit, config.Refresh),
ProcessCPU: emptyChartEntries(now, processCPUSampleLimit, config.Refresh),
SystemCPU: emptyChartEntries(now, systemCPUSampleLimit, config.Refresh),
DiskRead: emptyChartEntries(now, diskReadSampleLimit, config.Refresh),
DiskWrite: emptyChartEntries(now, diskWriteSampleLimit, config.Refresh),
history: &Message{
General: &GeneralMessage{
Commit: commit,
Version: fmt.Sprintf("v%d.%d.%d%s", params.VersionMajor, params.VersionMinor, params.VersionPatch, versionMeta),
},
System: &SystemMessage{
ActiveMemory: emptyChartEntries(now, activeMemorySampleLimit, config.Refresh),
VirtualMemory: emptyChartEntries(now, virtualMemorySampleLimit, config.Refresh),
NetworkIngress: emptyChartEntries(now, networkIngressSampleLimit, config.Refresh),
NetworkEgress: emptyChartEntries(now, networkEgressSampleLimit, config.Refresh),
ProcessCPU: emptyChartEntries(now, processCPUSampleLimit, config.Refresh),
SystemCPU: emptyChartEntries(now, systemCPUSampleLimit, config.Refresh),
DiskRead: emptyChartEntries(now, diskReadSampleLimit, config.Refresh),
DiskWrite: emptyChartEntries(now, diskWriteSampleLimit, config.Refresh),
},
},
commit: commit,
logdir: logdir,
}
return db, nil
}
// emptyChartEntries returns a ChartEntry array containing limit number of empty samples.
@@ -110,19 +121,20 @@ func emptyChartEntries(t time.Time, limit int, refresh time.Duration) ChartEntri
return ce
}
// Protocols is a meaningless implementation of node.Service.
// Protocols implements the node.Service interface.
func (db *Dashboard) Protocols() []p2p.Protocol { return nil }
// APIs is a meaningless implementation of node.Service.
// APIs implements the node.Service interface.
func (db *Dashboard) APIs() []rpc.API { return nil }
// Start implements node.Service, starting the data collection thread and the listening server of the dashboard.
// Start starts the data collection thread and the listening server of the dashboard.
// Implements the node.Service interface.
func (db *Dashboard) Start(server *p2p.Server) error {
log.Info("Starting dashboard")
db.wg.Add(2)
go db.collectData()
go db.collectLogs() // In case of removing this line change 2 back to 1 in wg.Add.
go db.streamLogs()
http.HandleFunc("/", db.webHandler)
http.Handle("/api", websocket.Handler(db.apiHandler))
@@ -138,7 +150,8 @@ func (db *Dashboard) Start(server *p2p.Server) error {
return nil
}
// Stop implements node.Service, stopping the data collection thread and the connection listener of the dashboard.
// Stop stops the data collection thread and the connection listener of the dashboard.
// Implements the node.Service interface.
func (db *Dashboard) Stop() error {
// Close the connection listener.
var errs []error
@@ -180,18 +193,7 @@ func (db *Dashboard) webHandler(w http.ResponseWriter, r *http.Request) {
path := r.URL.String()
if path == "/" {
path = "/dashboard.html"
}
// If the path of the assets is manually set
if db.config.Assets != "" {
blob, err := ioutil.ReadFile(filepath.Join(db.config.Assets, path))
if err != nil {
log.Warn("Failed to read file", "path", path, "err", err)
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Write(blob)
return
path = "/index.html"
}
blob, err := Asset(path[1:])
if err != nil {
@@ -207,7 +209,7 @@ func (db *Dashboard) apiHandler(conn *websocket.Conn) {
id := atomic.AddUint32(&nextID, 1)
client := &client{
conn: conn,
msg: make(chan Message, 128),
msg: make(chan *Message, 128),
logger: log.New("id", id),
}
done := make(chan struct{})
@@ -231,29 +233,10 @@ func (db *Dashboard) apiHandler(conn *websocket.Conn) {
}
}()
versionMeta := ""
if len(params.VersionMeta) > 0 {
versionMeta = fmt.Sprintf(" (%s)", params.VersionMeta)
}
// Send the past data.
client.msg <- Message{
General: &GeneralMessage{
Version: fmt.Sprintf("v%d.%d.%d%s", params.VersionMajor, params.VersionMinor, params.VersionPatch, versionMeta),
Commit: db.commit,
},
Home: &HomeMessage{
ActiveMemory: db.charts.ActiveMemory,
VirtualMemory: db.charts.VirtualMemory,
NetworkIngress: db.charts.NetworkIngress,
NetworkEgress: db.charts.NetworkEgress,
ProcessCPU: db.charts.ProcessCPU,
SystemCPU: db.charts.SystemCPU,
DiskRead: db.charts.DiskRead,
DiskWrite: db.charts.DiskWrite,
},
}
// Start tracking the connection and drop at connection loss.
db.lock.Lock()
// Send the past data.
client.msg <- deepcopy.Copy(db.history).(*Message)
// Start tracking the connection and drop at connection loss.
db.conns[id] = client
db.lock.Unlock()
defer func() {
@@ -262,27 +245,53 @@ func (db *Dashboard) apiHandler(conn *websocket.Conn) {
db.lock.Unlock()
}()
for {
fail := []byte{}
if _, err := conn.Read(fail); err != nil {
r := new(Request)
if err := websocket.JSON.Receive(conn, r); err != nil {
if err != io.EOF {
client.logger.Warn("Failed to receive request", "err", err)
}
close(done)
return
}
// Ignore all messages
if r.Logs != nil {
db.handleLogRequest(r.Logs, client)
}
}
}
// meterCollector returns a function, which retrieves a specific meter.
func meterCollector(name string) func() int64 {
if metric := metrics.DefaultRegistry.Get(name); metric != nil {
m := metric.(metrics.Meter)
return func() int64 {
return m.Count()
}
}
return func() int64 {
return 0
}
}
// collectData collects the required data to plot on the dashboard.
func (db *Dashboard) collectData() {
defer db.wg.Done()
systemCPUUsage := gosigar.Cpu{}
systemCPUUsage.Get()
var (
prevNetworkIngress = metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Count()
prevNetworkEgress = metrics.DefaultRegistry.Get("p2p/OutboundTraffic").(metrics.Meter).Count()
mem runtime.MemStats
collectNetworkIngress = meterCollector("p2p/InboundTraffic")
collectNetworkEgress = meterCollector("p2p/OutboundTraffic")
collectDiskRead = meterCollector("eth/db/chaindata/disk/read")
collectDiskWrite = meterCollector("eth/db/chaindata/disk/write")
prevNetworkIngress = collectNetworkIngress()
prevNetworkEgress = collectNetworkEgress()
prevProcessCPUTime = getProcessCPUTime()
prevSystemCPUUsage = systemCPUUsage
prevDiskRead = metrics.DefaultRegistry.Get("eth/db/chaindata/compact/input").(metrics.Meter).Count()
prevDiskWrite = metrics.DefaultRegistry.Get("eth/db/chaindata/compact/output").(metrics.Meter).Count()
prevDiskRead = collectDiskRead()
prevDiskWrite = collectDiskWrite()
frequency = float64(db.config.Refresh / time.Second)
numCPU = float64(runtime.NumCPU())
@@ -296,17 +305,17 @@ func (db *Dashboard) collectData() {
case <-time.After(db.config.Refresh):
systemCPUUsage.Get()
var (
curNetworkIngress = metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Count()
curNetworkEgress = metrics.DefaultRegistry.Get("p2p/OutboundTraffic").(metrics.Meter).Count()
curNetworkIngress = collectNetworkIngress()
curNetworkEgress = collectNetworkEgress()
curProcessCPUTime = getProcessCPUTime()
curSystemCPUUsage = systemCPUUsage
curDiskRead = metrics.DefaultRegistry.Get("eth/db/chaindata/compact/input").(metrics.Meter).Count()
curDiskWrite = metrics.DefaultRegistry.Get("eth/db/chaindata/compact/output").(metrics.Meter).Count()
curDiskRead = collectDiskRead()
curDiskWrite = collectDiskWrite()
deltaNetworkIngress = float64(curNetworkIngress - prevNetworkIngress)
deltaNetworkEgress = float64(curNetworkEgress - prevNetworkEgress)
deltaProcessCPUTime = curProcessCPUTime - prevProcessCPUTime
deltaSystemCPUUsage = systemCPUUsage.Delta(prevSystemCPUUsage)
deltaSystemCPUUsage = curSystemCPUUsage.Delta(prevSystemCPUUsage)
deltaDiskRead = curDiskRead - prevDiskRead
deltaDiskWrite = curDiskWrite - prevDiskWrite
)
@@ -319,7 +328,6 @@ func (db *Dashboard) collectData() {
now := time.Now()
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
activeMemory := &ChartEntry{
Time: now,
@@ -353,17 +361,20 @@ func (db *Dashboard) collectData() {
Time: now,
Value: float64(deltaDiskWrite) / frequency,
}
db.charts.ActiveMemory = append(db.charts.ActiveMemory[1:], activeMemory)
db.charts.VirtualMemory = append(db.charts.VirtualMemory[1:], virtualMemory)
db.charts.NetworkIngress = append(db.charts.NetworkIngress[1:], networkIngress)
db.charts.NetworkEgress = append(db.charts.NetworkEgress[1:], networkEgress)
db.charts.ProcessCPU = append(db.charts.ProcessCPU[1:], processCPU)
db.charts.SystemCPU = append(db.charts.SystemCPU[1:], systemCPU)
db.charts.DiskRead = append(db.charts.DiskRead[1:], diskRead)
db.charts.DiskWrite = append(db.charts.DiskRead[1:], diskWrite)
sys := db.history.System
db.lock.Lock()
sys.ActiveMemory = append(sys.ActiveMemory[1:], activeMemory)
sys.VirtualMemory = append(sys.VirtualMemory[1:], virtualMemory)
sys.NetworkIngress = append(sys.NetworkIngress[1:], networkIngress)
sys.NetworkEgress = append(sys.NetworkEgress[1:], networkEgress)
sys.ProcessCPU = append(sys.ProcessCPU[1:], processCPU)
sys.SystemCPU = append(sys.SystemCPU[1:], systemCPU)
sys.DiskRead = append(sys.DiskRead[1:], diskRead)
sys.DiskWrite = append(sys.DiskWrite[1:], diskWrite)
db.lock.Unlock()
db.sendToAll(&Message{
Home: &HomeMessage{
System: &SystemMessage{
ActiveMemory: ChartEntries{activeMemory},
VirtualMemory: ChartEntries{virtualMemory},
NetworkIngress: ChartEntries{networkIngress},
@@ -378,34 +389,12 @@ func (db *Dashboard) collectData() {
}
}
// collectLogs collects and sends the logs to the active dashboards.
func (db *Dashboard) collectLogs() {
defer db.wg.Done()
id := 1
// TODO (kurkomisi): log collection comes here.
for {
select {
case errc := <-db.quit:
errc <- nil
return
case <-time.After(db.config.Refresh / 2):
db.sendToAll(&Message{
Logs: &LogsMessage{
Log: []string{fmt.Sprintf("%-4d: This is a fake log.", id)},
},
})
id++
}
}
}
// sendToAll sends the given message to the active dashboards.
func (db *Dashboard) sendToAll(msg *Message) {
db.lock.Lock()
for _, c := range db.conns {
select {
case c.msg <- *msg:
case c.msg <- msg:
default:
c.conn.Close()
}
+288
View File
@@ -0,0 +1,288 @@
// 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/>.
package dashboard
import (
"bytes"
"encoding/json"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"sort"
"time"
"github.com/ethereum/go-ethereum/log"
"github.com/mohae/deepcopy"
"github.com/rjeczalik/notify"
)
var emptyChunk = json.RawMessage("[]")
// prepLogs creates a JSON array from the given log record buffer.
// Returns the prepared array and the position of the last '\n'
// character in the original buffer, or -1 if it doesn't contain any.
func prepLogs(buf []byte) (json.RawMessage, int) {
b := make(json.RawMessage, 1, len(buf)+1)
b[0] = '['
b = append(b, buf...)
last := -1
for i := 1; i < len(b); i++ {
if b[i] == '\n' {
b[i] = ','
last = i
}
}
if last < 0 {
return emptyChunk, -1
}
b[last] = ']'
return b[:last+1], last - 1
}
// handleLogRequest searches for the log file specified by the timestamp of the
// request, creates a JSON array out of it and sends it to the requesting client.
func (db *Dashboard) handleLogRequest(r *LogsRequest, c *client) {
files, err := ioutil.ReadDir(db.logdir)
if err != nil {
log.Warn("Failed to open logdir", "path", db.logdir, "err", err)
return
}
re := regexp.MustCompile(`\.log$`)
fileNames := make([]string, 0, len(files))
for _, f := range files {
if f.Mode().IsRegular() && re.MatchString(f.Name()) {
fileNames = append(fileNames, f.Name())
}
}
if len(fileNames) < 1 {
log.Warn("No log files in logdir", "path", db.logdir)
return
}
idx := sort.Search(len(fileNames), func(idx int) bool {
// Returns the smallest index such as fileNames[idx] >= r.Name,
// if there is no such index, returns n.
return fileNames[idx] >= r.Name
})
switch {
case idx < 0:
return
case idx == 0 && r.Past:
return
case idx >= len(fileNames):
return
case r.Past:
idx--
case idx == len(fileNames)-1 && fileNames[idx] == r.Name:
return
case idx == len(fileNames)-1 || (idx == len(fileNames)-2 && fileNames[idx] == r.Name):
// The last file is continuously updated, and its chunks are streamed,
// so in order to avoid log record duplication on the client side, it is
// handled differently. Its actual content is always saved in the history.
db.lock.Lock()
if db.history.Logs != nil {
c.msg <- &Message{
Logs: db.history.Logs,
}
}
db.lock.Unlock()
return
case fileNames[idx] == r.Name:
idx++
}
path := filepath.Join(db.logdir, fileNames[idx])
var buf []byte
if buf, err = ioutil.ReadFile(path); err != nil {
log.Warn("Failed to read file", "path", path, "err", err)
return
}
chunk, end := prepLogs(buf)
if end < 0 {
log.Warn("The file doesn't contain valid logs", "path", path)
return
}
c.msg <- &Message{
Logs: &LogsMessage{
Source: &LogFile{
Name: fileNames[idx],
Last: r.Past && idx == 0,
},
Chunk: chunk,
},
}
}
// streamLogs watches the file system, and when the logger writes
// the new log records into the files, picks them up, then makes
// JSON array out of them and sends them to the clients.
func (db *Dashboard) streamLogs() {
defer db.wg.Done()
var (
err error
errc chan error
)
defer func() {
if errc == nil {
errc = <-db.quit
}
errc <- err
}()
files, err := ioutil.ReadDir(db.logdir)
if err != nil {
log.Warn("Failed to open logdir", "path", db.logdir, "err", err)
return
}
var (
opened *os.File // File descriptor for the opened active log file.
buf []byte // Contains the recently written log chunks, which are not sent to the clients yet.
)
// The log records are always written into the last file in alphabetical order, because of the timestamp.
re := regexp.MustCompile(`\.log$`)
i := len(files) - 1
for i >= 0 && (!files[i].Mode().IsRegular() || !re.MatchString(files[i].Name())) {
i--
}
if i < 0 {
log.Warn("No log files in logdir", "path", db.logdir)
return
}
if opened, err = os.OpenFile(filepath.Join(db.logdir, files[i].Name()), os.O_RDONLY, 0600); err != nil {
log.Warn("Failed to open file", "name", files[i].Name(), "err", err)
return
}
defer opened.Close() // Close the lastly opened file.
fi, err := opened.Stat()
if err != nil {
log.Warn("Problem with file", "name", opened.Name(), "err", err)
return
}
db.lock.Lock()
db.history.Logs = &LogsMessage{
Source: &LogFile{
Name: fi.Name(),
Last: true,
},
Chunk: emptyChunk,
}
db.lock.Unlock()
watcher := make(chan notify.EventInfo, 10)
if err := notify.Watch(db.logdir, watcher, notify.Create); err != nil {
log.Warn("Failed to create file system watcher", "err", err)
return
}
defer notify.Stop(watcher)
ticker := time.NewTicker(db.config.Refresh)
defer ticker.Stop()
loop:
for err == nil || errc == nil {
select {
case event := <-watcher:
// Make sure that new log file was created.
if !re.Match([]byte(event.Path())) {
break
}
if opened == nil {
log.Warn("The last log file is not opened")
break loop
}
// The new log file's name is always greater,
// because it is created using the actual log record's time.
if opened.Name() >= event.Path() {
break
}
// Read the rest of the previously opened file.
chunk, err := ioutil.ReadAll(opened)
if err != nil {
log.Warn("Failed to read file", "name", opened.Name(), "err", err)
break loop
}
buf = append(buf, chunk...)
opened.Close()
if chunk, last := prepLogs(buf); last >= 0 {
// Send the rest of the previously opened file.
db.sendToAll(&Message{
Logs: &LogsMessage{
Chunk: chunk,
},
})
}
if opened, err = os.OpenFile(event.Path(), os.O_RDONLY, 0644); err != nil {
log.Warn("Failed to open file", "name", event.Path(), "err", err)
break loop
}
buf = buf[:0]
// Change the last file in the history.
fi, err := opened.Stat()
if err != nil {
log.Warn("Problem with file", "name", opened.Name(), "err", err)
break loop
}
db.lock.Lock()
db.history.Logs.Source.Name = fi.Name()
db.history.Logs.Chunk = emptyChunk
db.lock.Unlock()
case <-ticker.C: // Send log updates to the client.
if opened == nil {
log.Warn("The last log file is not opened")
break loop
}
// Read the new logs created since the last read.
chunk, err := ioutil.ReadAll(opened)
if err != nil {
log.Warn("Failed to read file", "name", opened.Name(), "err", err)
break loop
}
b := append(buf, chunk...)
chunk, last := prepLogs(b)
if last < 0 {
break
}
// Only keep the invalid part of the buffer, which can be valid after the next read.
buf = b[last+1:]
var l *LogsMessage
// Update the history.
db.lock.Lock()
if bytes.Equal(db.history.Logs.Chunk, emptyChunk) {
db.history.Logs.Chunk = chunk
l = deepcopy.Copy(db.history.Logs).(*LogsMessage)
} else {
b = make([]byte, len(db.history.Logs.Chunk)+len(chunk)-1)
copy(b, db.history.Logs.Chunk)
b[len(db.history.Logs.Chunk)-1] = ','
copy(b[len(db.history.Logs.Chunk):], chunk[1:])
db.history.Logs.Chunk = b
l = &LogsMessage{Chunk: chunk}
}
db.lock.Unlock()
db.sendToAll(&Message{Logs: l})
case errc = <-db.quit:
break loop
}
}
}
+39 -18
View File
@@ -16,7 +16,10 @@
package dashboard
import "time"
import (
"encoding/json"
"time"
)
type Message struct {
General *GeneralMessage `json:"general,omitempty"`
@@ -28,27 +31,20 @@ type Message struct {
Logs *LogsMessage `json:"logs,omitempty"`
}
type ChartEntries []*ChartEntry
type ChartEntry struct {
Time time.Time `json:"time,omitempty"`
Value float64 `json:"value,omitempty"`
}
type GeneralMessage struct {
Version string `json:"version,omitempty"`
Commit string `json:"commit,omitempty"`
}
type HomeMessage struct {
ActiveMemory ChartEntries `json:"activeMemory,omitempty"`
VirtualMemory ChartEntries `json:"virtualMemory,omitempty"`
NetworkIngress ChartEntries `json:"networkIngress,omitempty"`
NetworkEgress ChartEntries `json:"networkEgress,omitempty"`
ProcessCPU ChartEntries `json:"processCPU,omitempty"`
SystemCPU ChartEntries `json:"systemCPU,omitempty"`
DiskRead ChartEntries `json:"diskRead,omitempty"`
DiskWrite ChartEntries `json:"diskWrite,omitempty"`
}
type ChartEntries []*ChartEntry
type ChartEntry struct {
Time time.Time `json:"time,omitempty"`
Value float64 `json:"value,omitempty"`
/* TODO (kurkomisi) */
}
type ChainMessage struct {
@@ -64,9 +60,34 @@ type NetworkMessage struct {
}
type SystemMessage struct {
/* TODO (kurkomisi) */
ActiveMemory ChartEntries `json:"activeMemory,omitempty"`
VirtualMemory ChartEntries `json:"virtualMemory,omitempty"`
NetworkIngress ChartEntries `json:"networkIngress,omitempty"`
NetworkEgress ChartEntries `json:"networkEgress,omitempty"`
ProcessCPU ChartEntries `json:"processCPU,omitempty"`
SystemCPU ChartEntries `json:"systemCPU,omitempty"`
DiskRead ChartEntries `json:"diskRead,omitempty"`
DiskWrite ChartEntries `json:"diskWrite,omitempty"`
}
// LogsMessage wraps up a log chunk. If Source isn't present, the chunk is a stream chunk.
type LogsMessage struct {
Log []string `json:"log,omitempty"`
Source *LogFile `json:"source,omitempty"` // Attributes of the log file.
Chunk json.RawMessage `json:"chunk"` // Contains log records.
}
// LogFile contains the attributes of a log file.
type LogFile struct {
Name string `json:"name"` // The name of the file.
Last bool `json:"last"` // Denotes if the actual log file is the last one in the directory.
}
// Request represents the client request.
type Request struct {
Logs *LogsRequest `json:"logs,omitempty"`
}
type LogsRequest struct {
Name string `json:"name"` // The request handler searches for log file based on this file name.
Past bool `json:"past"` // Denotes whether the client wants the previous or the next file.
}