dashboard: maintain legacy

This commit is contained in:
siddharth0a 2024-04-02 20:21:06 +09:00
parent 22965ba57e
commit 50a300ebcb
34 changed files with 50211 additions and 0 deletions

58
dashboard/README.md Normal file
View file

@ -0,0 +1,58 @@
## Go Ethereum Dashboard
The dashboard is a data visualizer integrated into geth, intended to collect and visualize useful information of an Ethereum node. It consists of two parts:
* The client visualizes the collected data.
* The server collects the data, and updates the clients.
The client's UI uses [React][React] with JSX syntax, which is validated by the [ESLint][ESLint] linter mostly according to the [Airbnb React/JSX Style Guide][Airbnb]. The style is defined in the `.eslintrc` configuration file. The resources are bundled into a single `bundle.js` file using [Webpack][Webpack], which relies on the `webpack.config.js`. The bundled file is referenced from `dashboard.html` and takes part in the `assets.go` too. The necessary dependencies for the module bundler are gathered by [Node.js][Node.js].
### Development and bundling
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 && 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 `yarn dev` to watch for file system changes and refresh the browser automatically.
```
$ geth --dashboard --vmodule=dashboard=5
$ (cd dashboard/assets && yarn dev)
```
To bundle up the final UI into Geth, run `go generate`:
```
$ (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 `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.
### Have fun
[Webpack][Webpack] offers handy tools for visualizing the bundle's dependency tree and space usage.
* 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`
[React]: https://reactjs.org/
[ESLint]: https://eslint.org/
[Airbnb]: https://github.com/airbnb/javascript/tree/master/react
[Webpack]: https://webpack.github.io/
[WA]: http://webpack.github.io/analyse/
[WV]: http://chrisbateman.github.io/webpack-visualizer/
[Node.js]: https://nodejs.org/en/
[Flow]: https://flow.org/
[Atom]: https://atom.io/
[Atom config]: https://medium.com/@fastphrase/integrating-flow-into-a-react-project-fbbc2f130eed
[Nuclide]: https://nuclide.io/docs/quick-start/getting-started/
[flow-typed]: https://github.com/flowtype/flow-typed

38665
dashboard/assets.go Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
node_modules/* #ignored by default
flow-typed/*
bundle.js

View file

@ -0,0 +1,81 @@
// Copyright 2017 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/>.
// React syntax style mostly according to https://github.com/airbnb/javascript/tree/master/react
{
"env": {
"browser": true,
"node": true,
"es6": true
},
"parser": "babel-eslint",
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 6,
"ecmaFeatures": {
"jsx": true
}
},
"extends": [
"eslint:recommended",
"airbnb",
"plugin:flowtype/recommended",
"plugin:react/recommended"
],
"plugins": [
"flowtype",
"react"
],
"rules": {
"no-tabs": "off",
"indent": ["error", "tab"],
"react/jsx-indent": ["error", "tab"],
"react/jsx-indent-props": ["error", "tab"],
"react/prefer-stateless-function": "off",
"react/destructuring-assignment": ["error", "always", {"ignoreClassFields": true}],
"jsx-quotes": ["error", "prefer-single"],
"no-plusplus": "off",
"no-console": ["error", { "allow": ["error"] }],
// Specifies the maximum length of a line.
"max-len": ["warn", 120, 2, {
"ignoreUrls": true,
"ignoreComments": false,
"ignoreRegExpLiterals": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true
}],
// Enforces consistent spacing between keys and values in object literal properties.
"key-spacing": ["error", {"align": {
"beforeColon": false,
"afterColon": true,
"on": "value"
}}],
// Prohibits padding inside curly braces.
"object-curly-spacing": ["error", "never"],
"no-use-before-define": "off", // message types
"default-case": "off"
},
"settings": {
"import/resolver": {
"node": {
"paths": ["components"] // import './components/Component' -> import 'Component'
}
},
"flowtype": {
"onlyFilesWithFlowAnnotation": true
}
}
}

View file

@ -0,0 +1,11 @@
[ignore]
<PROJECT_ROOT>/node_modules/material-ui/.*\.js\.flow
[libs]
<PROJECT_ROOT>/flow-typed/
node_modules/jss/flow-typed
[options]
include_warnings=true
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=components

View file

@ -0,0 +1,92 @@
// @flow
// Copyright 2017 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 {faHome, faLink, faGlobeEurope, faTachometerAlt, faList} from '@fortawesome/free-solid-svg-icons';
import {faCreditCard} from '@fortawesome/free-regular-svg-icons';
type ProvidedMenuProp = {|title: string, icon: string|};
const menuSkeletons: Array<{|id: string, menu: ProvidedMenuProp|}> = [
{
id: 'home',
menu: {
title: 'Home',
icon: faHome,
},
}, {
id: 'chain',
menu: {
title: 'Chain',
icon: faLink,
},
}, {
id: 'txpool',
menu: {
title: 'TxPool',
icon: faCreditCard,
},
}, {
id: 'network',
menu: {
title: 'Network',
icon: faGlobeEurope,
},
}, {
id: 'system',
menu: {
title: 'System',
icon: faTachometerAlt,
},
}, {
id: 'logs',
menu: {
title: 'Logs',
icon: faList,
},
},
];
export type MenuProp = {|...ProvidedMenuProp, id: string|};
// The sidebar menu and the main content are rendered based on these elements.
// Using the id is circumstantial in some cases, so it is better to insert it also as a value.
// This way the mistyping is prevented.
export const MENU: Map<string, {...MenuProp}> = new Map(menuSkeletons.map(({id, menu}) => ([id, {id, ...menu}])));
export const DURATION = 200;
export const chartStrokeWidth = 0.2;
export const styles = {
light: {
color: 'rgba(255, 255, 255, 0.54)',
},
};
// unit contains the units for the bytePlotter.
export const unit = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'];
// simplifyBytes returns the simplified version of the given value followed by the unit.
export const simplifyBytes = (x: number) => {
let i = 0;
for (; x > 1024 && i < 8; i++) {
x /= 1024;
}
return x.toFixed(2).toString().concat(' ', unit[i], 'B');
};
// hues contains predefined colors for gradient stop colors.
export const hues = ['#00FF00', '#FFFF00', '#FF7F00', '#FF0000'];
export const hueScale = [0, 2048, 102400, 2097152];

View file

@ -0,0 +1,63 @@
// @flow
// Copyright 2017 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 SideBar from './SideBar';
import Main from './Main';
import type {Content} from '../types/content';
// styles contains the constant styles of the component.
const styles = {
body: {
display: 'flex',
width: '100%',
height: '92%',
},
};
export type Props = {
opened: boolean,
changeContent: string => void,
active: string,
content: Content,
shouldUpdate: Object,
send: string => void,
};
// Body renders the body of the dashboard.
class Body extends Component<Props> {
render() {
return (
<div style={styles.body}>
<SideBar
opened={this.props.opened}
changeContent={this.props.changeContent}
/>
<Main
active={this.props.active}
content={this.props.content}
shouldUpdate={this.props.shouldUpdate}
send={this.props.send}
/>
</div>
);
}
}
export default Body;

View file

@ -0,0 +1,57 @@
// @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 type {ChildrenArray} from 'react';
import Grid from '@material-ui/core/Grid';
// styles contains the constant styles of the component.
const styles = {
container: {
flexWrap: 'nowrap',
height: '100%',
maxWidth: '100%',
margin: 0,
},
item: {
flex: 1,
padding: 0,
},
};
export type Props = {
children: ChildrenArray<React$Element<any>>,
};
// ChartRow renders a row of equally sized responsive charts.
class ChartRow extends Component<Props> {
render() {
return (
<Grid container direction='row' style={styles.container} justify='space-between'>
{React.Children.map(this.props.children, child => (
<Grid item xs style={styles.item}>
{child}
</Grid>
))}
</Grid>
);
}
}
export default ChartRow;

View file

@ -0,0 +1,84 @@
// @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 Typography from '@material-ui/core/Typography';
import {styles, simplifyBytes} from '../common';
// multiplier multiplies a number by another.
export const multiplier = <T>(by: number = 1) => (x: number) => x * by;
// percentPlotter renders a tooltip, which displays the value of the payload followed by a percent sign.
export const percentPlotter = <T>(text: string, mapper: (T => T) = multiplier(1)) => (payload: T) => {
const p = mapper(payload);
if (typeof p !== 'number') {
return null;
}
return (
<Typography type='caption' color='inherit'>
<span style={styles.light}>{text}</span> {p.toFixed(2)} %
</Typography>
);
};
// bytePlotter renders a tooltip, which displays the payload as a byte value.
export const bytePlotter = <T>(text: string, mapper: (T => T) = multiplier(1)) => (payload: T) => {
const p = mapper(payload);
if (typeof p !== 'number') {
return null;
}
return (
<Typography type='caption' color='inherit'>
<span style={styles.light}>{text}</span> {simplifyBytes(p)}
</Typography>
);
};
// bytePlotter renders a tooltip, which displays the payload as a byte value followed by '/s'.
export const bytePerSecPlotter = <T>(text: string, mapper: (T => T) = multiplier(1)) => (payload: T) => {
const p = mapper(payload);
if (typeof p !== 'number') {
return null;
}
return (
<Typography type='caption' color='inherit'>
<span style={styles.light}>{text}</span>
{simplifyBytes(p)}/s
</Typography>
);
};
export type Props = {
active: boolean,
payload: Object,
tooltip: <T>(text: string, mapper?: T => T) => (payload: mixed) => null | React$Element<any>,
};
// CustomTooltip takes a tooltip function, and uses it to plot the active value of the chart.
class CustomTooltip extends Component<Props> {
render() {
const {active, payload, tooltip} = this.props;
if (!active || typeof tooltip !== 'function' || !Array.isArray(payload) || payload.length < 1) {
return null;
}
return tooltip(payload[0].value);
}
}
export default CustomTooltip;

View file

@ -0,0 +1,258 @@
// @flow
// Copyright 2017 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 {hot} from 'react-hot-loader';
import withStyles from '@material-ui/core/styles/withStyles';
import Header from 'Header';
import Body from 'Body';
import {inserter as logInserter, SAME} from 'Logs';
import {inserter as peerInserter} from 'Network';
import {MENU} from '../common';
import type {Content} from '../types/content';
// 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
// structure, except that it contains functions where the original data needs to be
// updated. These functions are used to handle the update.
//
// Since the messages have the same shape as the state content, this approach allows
// the generalization of the message handling. The only necessary thing is to set a
// handler function for every path of the state in order to maximize the flexibility
// of the update.
const deepUpdate = (updater: Object, update: Object, prev: Object): $Shape<Content> => {
if (typeof update === 'undefined') {
return prev;
}
if (typeof updater === 'function') {
return updater(update, prev);
}
const updated = {};
Object.keys(prev).forEach((key) => {
updated[key] = deepUpdate(updater[key], update[key], prev[key]);
});
return updated;
};
// shouldUpdate returns the structure of a message. It is used to prevent unnecessary render
// method triggerings. In the affected component's shouldComponentUpdate method it can be checked
// whether the involved data was changed or not by checking the message structure.
//
// We could return the message itself too, but it's safer not to give access to it.
const shouldUpdate = (updater: Object, msg: Object) => {
const su = {};
Object.keys(msg).forEach((key) => {
su[key] = typeof updater[key] !== 'function' ? shouldUpdate(updater[key], msg[key]) : true;
});
return su;
};
// replacer is a state updater function, which replaces the original data.
const replacer = <T>(update: T) => update;
// appender is a state updater function, which appends the update data to the
// existing data. limit defines the maximum allowed size of the created array,
// mapper maps the update data.
const appender = <T>(limit: number, mapper = replacer) => (update: Array<T>, prev: Array<T>) => [
...prev,
...update.map(sample => mapper(sample)),
].slice(-limit);
// 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: {},
chain: {},
txpool: {},
network: {
peers: {
bundles: {},
},
diff: [],
},
system: {
activeMemory: [],
virtualMemory: [],
networkIngress: [],
networkEgress: [],
processCPU: [],
systemCPU: [],
diskRead: [],
diskWrite: [],
},
logs: {
chunks: [],
endTop: false,
endBottom: true,
topChanged: SAME,
bottomChanged: SAME,
},
});
// updaters contains the state updater functions for each path of the state.
//
// TODO (kurkomisi): Define a tricky type which embraces the content and the updaters.
const updaters = {
general: {
version: replacer,
commit: replacer,
},
home: null,
chain: null,
txpool: null,
network: peerInserter(200),
system: {
activeMemory: appender(200),
virtualMemory: appender(200),
networkIngress: appender(200),
networkEgress: appender(200),
processCPU: appender(200),
systemCPU: appender(200),
diskRead: appender(200),
diskWrite: appender(200),
},
logs: logInserter(5),
};
// styles contains the constant styles of the component.
const styles = {
dashboard: {
display: 'flex',
flexFlow: 'column',
width: '100%',
height: '100%',
zIndex: 1,
overflow: 'hidden',
},
};
// themeStyles returns the styles generated from the theme for the component.
const themeStyles: Object = (theme: Object) => ({
dashboard: {
background: theme.palette.background.default,
},
});
export type Props = {
classes: Object, // injected by withStyles()
};
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
server: ?WebSocket,
};
// Dashboard is the main component, which renders the whole page, makes connection with the server and
// listens for messages. When there is an incoming message, updates the page's content correspondingly.
class Dashboard extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
active: MENU.get('home').id,
sideBar: true,
content: defaultContent(),
shouldUpdate: {},
server: null,
};
}
// componentDidMount initiates the establishment of the first websocket connection after the component is rendered.
componentDidMount() {
this.reconnect();
}
// reconnect establishes a websocket connection with the server, listens for incoming messages
// and tries to reconnect on connection loss.
reconnect = () => {
const host = process.env.NODE_ENV === 'production' ? window.location.host : 'localhost:8080';
const server = new WebSocket(`${((window.location.protocol === 'https:') ? 'wss://' : 'ws://')}${host}/api`);
server.onopen = () => {
this.setState({content: defaultContent(), shouldUpdate: {}, server});
};
server.onmessage = (event) => {
const msg: $Shape<Content> = JSON.parse(event.data);
if (!msg) {
console.error(`Incoming message is ${msg}`);
return;
}
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 => ({
content: deepUpdate(updaters, msg, prevState.content),
shouldUpdate: shouldUpdate(updaters, msg),
}));
};
// changeContent sets the active label, which is used at the content rendering.
changeContent = (newActive: string) => {
this.setState(prevState => (prevState.active !== newActive ? {active: newActive} : {}));
};
// switchSideBar opens or closes the sidebar's state.
switchSideBar = () => {
this.setState(prevState => ({sideBar: !prevState.sideBar}));
};
render() {
return (
<div className={this.props.classes.dashboard} style={styles.dashboard}>
<Header
switchSideBar={this.switchSideBar}
/>
<Body
opened={this.state.sideBar}
changeContent={this.changeContent}
active={this.state.active}
content={this.state.content}
shouldUpdate={this.state.shouldUpdate}
send={this.send}
/>
</div>
);
}
}
export default hot(module)(withStyles(themeStyles)(Dashboard));

View file

@ -0,0 +1,211 @@
// @flow
// Copyright 2017 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 withStyles from '@material-ui/core/styles/withStyles';
import Typography from '@material-ui/core/Typography';
import Grid from '@material-ui/core/Grid';
import ResponsiveContainer from 'recharts/es6/component/ResponsiveContainer';
import AreaChart from 'recharts/es6/chart/AreaChart';
import Area from 'recharts/es6/cartesian/Area';
import ReferenceLine from 'recharts/es6/cartesian/ReferenceLine';
import Label from 'recharts/es6/component/Label';
import Tooltip from 'recharts/es6/component/Tooltip';
import ChartRow from 'ChartRow';
import CustomTooltip, {bytePlotter, bytePerSecPlotter, percentPlotter, multiplier} from 'CustomTooltip';
import {chartStrokeWidth, styles as commonStyles} from '../common';
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';
const cpuLabelTop = 'Process load';
const cpuLabelBottom = 'System load';
const memoryLabelTop = 'Active memory';
const memoryLabelBottom = 'Virtual memory';
const diskLabelTop = 'Disk read';
const diskLabelBottom = 'Disk write';
const trafficLabelTop = 'Download';
const trafficLabelBottom = 'Upload';
// styles contains the constant styles of the component.
const styles = {
footer: {
maxWidth: '100%',
flexWrap: 'nowrap',
margin: 0,
},
chartRowWrapper: {
height: '100%',
padding: 0,
},
doubleChartWrapper: {
height: '100%',
width: '99%',
},
link: {
color: 'inherit',
textDecoration: 'none',
},
};
// themeStyles returns the styles generated from the theme for the component.
const themeStyles: Object = (theme: Object) => ({
footer: {
backgroundColor: theme.palette.grey[900],
color: theme.palette.getContrastText(theme.palette.grey[900]),
zIndex: theme.zIndex.appBar,
height: theme.spacing.unit * 10,
},
});
export type Props = {
classes: Object, // injected by withStyles()
theme: Object,
general: General,
system: System,
shouldUpdate: Object,
};
type State = {};
// Footer renders the footer of the dashboard.
class Footer extends Component<Props, State> {
shouldComponentUpdate(nextProps: Readonly<Props>, nextState: Readonly<State>, nextContext: any) {
return typeof nextProps.shouldUpdate.general !== 'undefined' || typeof nextProps.shouldUpdate.system !== 'undefined';
}
// halfHeightChart renders an area chart with half of the height of its parent.
halfHeightChart = (chartProps, tooltip, areaProps, label, position) => (
<ResponsiveContainer width='100%' height='50%'>
<AreaChart {...chartProps}>
{!tooltip || (<Tooltip cursor={false} content={<CustomTooltip tooltip={tooltip} />} />)}
<Area isAnimationActive={false} strokeWidth={chartStrokeWidth} type='monotone' {...areaProps} />
<ReferenceLine x={0} strokeWidth={0}>
<Label fill={areaProps.fill} value={label} position={position} />
</ReferenceLine>
</AreaChart>
</ResponsiveContainer>
);
// doubleChart renders a pair of charts separated by the baseline.
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';
return (
<div style={styles.doubleChartWrapper}>
{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},
topChart.label,
'insideBottomLeft',
)}
{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},
bottomChart.label,
'insideTopLeft',
)}
</div>
);
};
render() {
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(
FOOTER_SYNC_ID,
CPU,
{data: system.processCPU, tooltip: percentPlotter(cpuLabelTop), label: cpuLabelTop},
{data: system.systemCPU, tooltip: percentPlotter(cpuLabelBottom, multiplier(-1)), label: cpuLabelBottom},
)}
{this.doubleChart(
FOOTER_SYNC_ID,
MEMORY,
{data: system.activeMemory, tooltip: bytePlotter(memoryLabelTop), label: memoryLabelTop},
{data: system.virtualMemory, tooltip: bytePlotter(memoryLabelBottom, multiplier(-1)), label: memoryLabelBottom},
)}
{this.doubleChart(
FOOTER_SYNC_ID,
DISK,
{data: system.diskRead, tooltip: bytePerSecPlotter(diskLabelTop), label: diskLabelTop},
{data: system.diskWrite, tooltip: bytePerSecPlotter(diskLabelBottom, multiplier(-1)), label: diskLabelBottom},
)}
{this.doubleChart(
FOOTER_SYNC_ID,
TRAFFIC,
{data: system.networkIngress, tooltip: bytePerSecPlotter(trafficLabelTop), label: trafficLabelTop},
{data: system.networkEgress, tooltip: bytePerSecPlotter(trafficLabelBottom, multiplier(-1)), label: trafficLabelBottom},
)}
</ChartRow>
</Grid>
<Grid item>
<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/cryptoecc/ETH-ECC/commit/${general.commit}`}
target='_blank'
rel='noopener noreferrer'
style={styles.link}
>
{general.commit.substring(0, 8)}
</a>
</Typography>
)}
</Grid>
</Grid>
);
}
}
export default withStyles(themeStyles)(Footer);

View file

@ -0,0 +1,81 @@
// @flow
// Copyright 2017 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 withStyles from '@material-ui/core/styles/withStyles';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import IconButton from '@material-ui/core/IconButton';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faBars} from '@fortawesome/free-solid-svg-icons';
import Typography from '@material-ui/core/Typography';
// styles contains the constant styles of the component.
const styles = {
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.grey[900],
color: theme.palette.getContrastText(theme.palette.grey[900]),
zIndex: theme.zIndex.appBar,
},
toolbar: {
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
},
title: {
paddingLeft: theme.spacing.unit,
fontSize: 3 * theme.spacing.unit,
},
});
export type Props = {
classes: Object, // injected by withStyles()
switchSideBar: () => void,
};
// Header renders the header of the dashboard.
class Header extends Component<Props> {
render() {
const {classes} = this.props;
return (
<AppBar position='static' className={classes.header} style={styles.header}>
<Toolbar className={classes.toolbar} style={styles.toolbar}>
<IconButton onClick={this.props.switchSideBar}>
<FontAwesomeIcon icon={faBars} />
</IconButton>
<Typography type='title' color='inherit' noWrap className={classes.title}>
Go Ethereum Dashboard
</Typography>
</Toolbar>
</AppBar>
);
}
}
export default withStyles(themeStyles)(Header);

View file

@ -0,0 +1,327 @@
// @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 from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
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.
export const ADDED = 1;
export const SAME = 0;
export 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;
const 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;

View file

@ -0,0 +1,144 @@
// @flow
// Copyright 2017 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 withStyles from '@material-ui/core/styles/withStyles';
import Network from 'Network';
import Logs from 'Logs';
import Footer from 'Footer';
import {MENU} from '../common';
import type {Content} from '../types/content';
// styles contains the constant styles of the component.
const styles = {
wrapper: {
display: 'flex',
flexDirection: 'column',
width: '100%',
},
content: {
flex: 1,
overflow: 'auto',
},
};
// themeStyles returns the styles generated from the theme for the component.
const themeStyles = theme => ({
content: {
backgroundColor: theme.palette.background.default,
padding: theme.spacing.unit * 3,
},
});
export type Props = {
classes: Object,
active: string,
content: Content,
shouldUpdate: Object,
send: string => void,
};
type State = {};
// Main renders the chosen content.
class Main extends Component<Props, State> {
constructor(props) {
super(props);
this.container = React.createRef();
this.content = React.createRef();
}
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();
}
};
getSnapshotBeforeUpdate(prevProps: Readonly<P>, prevState: Readonly<S>) {
if (this.content && typeof this.content.beforeUpdate === 'function') {
return this.content.beforeUpdate();
}
return null;
}
render() {
const {
classes, active, content, shouldUpdate,
} = this.props;
let children = null;
switch (active) {
case MENU.get('home').id:
children = <div>Work in progress.</div>;
break;
case MENU.get('chain').id:
children = <div>Work in progress.</div>;
break;
case MENU.get('txpool').id:
children = <div>Work in progress.</div>;
break;
case MENU.get('network').id:
children = <Network
content={this.props.content.network}
container={this.container}
/>;
break;
case MENU.get('system').id:
children = <div>Work in progress.</div>;
break;
case MENU.get('logs').id:
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}
ref={(ref) => { this.container = ref; }}
onScroll={this.onScroll}
>
{children}
</div>
<Footer
general={content.general}
system={content.system}
shouldUpdate={shouldUpdate}
/>
</div>
);
}
}
export default withStyles(themeStyles)(Main);

View file

@ -0,0 +1,529 @@
// @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 Table from '@material-ui/core/Table';
import TableHead from '@material-ui/core/TableHead';
import TableBody from '@material-ui/core/TableBody';
import TableRow from '@material-ui/core/TableRow';
import TableCell from '@material-ui/core/TableCell';
import Grid from '@material-ui/core/Grid/Grid';
import Typography from '@material-ui/core/Typography';
import {AreaChart, Area, Tooltip, YAxis} from 'recharts';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faCircle as fasCircle} from '@fortawesome/free-solid-svg-icons';
import {faCircle as farCircle} from '@fortawesome/free-regular-svg-icons';
import convert from 'color-convert';
import CustomTooltip, {bytePlotter, multiplier} from 'CustomTooltip';
import type {Network as NetworkType, PeerEvent} from '../types/content';
import {styles as commonStyles, chartStrokeWidth, hues, hueScale} from '../common';
// Peer chart dimensions.
const trafficChartHeight = 18;
const trafficChartWidth = 400;
// setMaxIngress adjusts the peer chart's gradient values based on the given value.
const setMaxIngress = (peer, value) => {
peer.maxIngress = value;
peer.ingressGradient = [];
peer.ingressGradient.push({offset: hueScale[0], color: hues[0]});
let i = 1;
for (; i < hues.length && value > hueScale[i]; i++) {
peer.ingressGradient.push({offset: Math.floor(hueScale[i] * 100 / value), color: hues[i]});
}
i--;
if (i < hues.length - 1) {
// Usually the maximum value gets between two points on the predefined
// color scale (e.g. 123KB is somewhere between 100KB (#FFFF00) and
// 1MB (#FF0000)), and the charts need to be comparable by the colors,
// so we have to calculate the last hue using the maximum value and the
// surrounding hues in order to avoid the uniformity of the top colors
// on the charts. For this reason the two hues are translated into the
// CIELAB color space, and the top color will be their weighted average
// (CIELAB is perceptually uniform, meaning that any point on the line
// between two pure color points is also a pure color, so the weighted
// average will not lose from the saturation).
//
// In case the maximum value is greater than the biggest predefined
// scale value, the top of the chart will have uniform color.
const lastHue = convert.hex.lab(hues[i]);
const proportion = (value - hueScale[i]) * 100 / (hueScale[i + 1] - hueScale[i]);
convert.hex.lab(hues[i + 1]).forEach((val, j) => {
lastHue[j] = (lastHue[j] * proportion + val * (100 - proportion)) / 100;
});
peer.ingressGradient.push({offset: 100, color: `#${convert.lab.hex(lastHue)}`});
}
};
// setMaxEgress adjusts the peer chart's gradient values based on the given value.
// In case of the egress the chart is upside down, so the gradients need to be
// calculated inversely compared to the ingress.
const setMaxEgress = (peer, value) => {
peer.maxEgress = value;
peer.egressGradient = [];
peer.egressGradient.push({offset: 100 - hueScale[0], color: hues[0]});
let i = 1;
for (; i < hues.length && value > hueScale[i]; i++) {
peer.egressGradient.unshift({offset: 100 - Math.floor(hueScale[i] * 100 / value), color: hues[i]});
}
i--;
if (i < hues.length - 1) {
// Calculate the last hue.
const lastHue = convert.hex.lab(hues[i]);
const proportion = (value - hueScale[i]) * 100 / (hueScale[i + 1] - hueScale[i]);
convert.hex.lab(hues[i + 1]).forEach((val, j) => {
lastHue[j] = (lastHue[j] * proportion + val * (100 - proportion)) / 100;
});
peer.egressGradient.unshift({offset: 0, color: `#${convert.lab.hex(lastHue)}`});
}
};
// setIngressChartAttributes searches for the maximum value of the ingress
// samples, and adjusts the peer chart's gradient values accordingly.
const setIngressChartAttributes = (peer) => {
let max = 0;
peer.ingress.forEach(({value}) => {
if (value > max) {
max = value;
}
});
setMaxIngress(peer, max);
};
// setEgressChartAttributes searches for the maximum value of the egress
// samples, and adjusts the peer chart's gradient values accordingly.
const setEgressChartAttributes = (peer) => {
let max = 0;
peer.egress.forEach(({value}) => {
if (value > max) {
max = value;
}
});
setMaxEgress(peer, max);
};
// inserter is a state updater function for the main component, which handles the peers.
export const inserter = (sampleLimit: number) => (update: NetworkType, prev: NetworkType) => {
// The first message contains the metered peer history.
if (update.peers && update.peers.bundles) {
prev.peers = update.peers;
Object.values(prev.peers.bundles).forEach((bundle) => {
if (bundle.knownPeers) {
Object.values(bundle.knownPeers).forEach((peer) => {
if (!peer.maxIngress) {
setIngressChartAttributes(peer);
}
if (!peer.maxEgress) {
setEgressChartAttributes(peer);
}
});
}
});
}
if (Array.isArray(update.diff)) {
update.diff.forEach((event: PeerEvent) => {
if (!event.ip) {
console.error('Peer event without IP', event);
return;
}
switch (event.remove) {
case 'bundle': {
delete prev.peers.bundles[event.ip];
return;
}
case 'known': {
if (!event.id) {
console.error('Remove known peer event without ID', event.ip);
return;
}
const bundle = prev.peers.bundles[event.ip];
if (!bundle || !bundle.knownPeers || !bundle.knownPeers[event.id]) {
console.error('No known peer to remove', event.ip, event.id);
return;
}
delete bundle.knownPeers[event.id];
return;
}
case 'attempt': {
const bundle = prev.peers.bundles[event.ip];
if (!bundle || !Array.isArray(bundle.attempts) || bundle.attempts.length < 1) {
console.error('No unknown peer to remove', event.ip);
return;
}
bundle.attempts.splice(0, 1);
return;
}
}
if (!prev.peers.bundles[event.ip]) {
prev.peers.bundles[event.ip] = {
location: {
country: '',
city: '',
latitude: 0,
longitude: 0,
},
knownPeers: {},
attempts: [],
};
}
const bundle = prev.peers.bundles[event.ip];
if (event.location) {
bundle.location = event.location;
return;
}
if (!event.id) {
if (!bundle.attempts) {
bundle.attempts = [];
}
bundle.attempts.push({
connected: event.connected,
disconnected: event.disconnected,
});
return;
}
if (!bundle.knownPeers) {
bundle.knownPeers = {};
}
if (!bundle.knownPeers[event.id]) {
bundle.knownPeers[event.id] = {
connected: [],
disconnected: [],
ingress: [],
egress: [],
active: false,
};
}
const peer = bundle.knownPeers[event.id];
if (!peer.maxIngress) {
setIngressChartAttributes(peer);
}
if (!peer.maxEgress) {
setEgressChartAttributes(peer);
}
if (event.connected) {
if (!peer.connected) {
console.warn('peer.connected should exist');
peer.connected = [];
}
peer.connected.push(event.connected);
}
if (event.disconnected) {
if (!peer.disconnected) {
console.warn('peer.disconnected should exist');
peer.disconnected = [];
}
peer.disconnected.push(event.disconnected);
}
switch (event.activity) {
case 'active':
peer.active = true;
break;
case 'inactive':
peer.active = false;
break;
}
if (Array.isArray(event.ingress) && Array.isArray(event.egress)) {
if (event.ingress.length !== event.egress.length) {
console.error('Different traffic sample length', event);
return;
}
// Check if there is a new maximum value, and reset the colors in case.
let maxIngress = peer.maxIngress;
event.ingress.forEach(({value}) => {
if (value > maxIngress) {
maxIngress = value;
}
});
if (maxIngress > peer.maxIngress) {
setMaxIngress(peer, maxIngress);
}
// Push the new values.
peer.ingress.splice(peer.ingress.length, 0, ...event.ingress);
const ingressDiff = peer.ingress.length - sampleLimit;
if (ingressDiff > 0) {
// Check if the maximum value is in the beginning.
let i = 0;
while (i < ingressDiff && peer.ingress[i].value < peer.maxIngress) {
i++;
}
// Remove the old values from the beginning.
peer.ingress.splice(0, ingressDiff);
if (i < ingressDiff) {
// Reset the colors if the maximum value leaves the chart.
setIngressChartAttributes(peer);
}
}
// Check if there is a new maximum value, and reset the colors in case.
let maxEgress = peer.maxEgress;
event.egress.forEach(({value}) => {
if (value > maxEgress) {
maxEgress = value;
}
});
if (maxEgress > peer.maxEgress) {
setMaxEgress(peer, maxEgress);
}
// Push the new values.
peer.egress.splice(peer.egress.length, 0, ...event.egress);
const egressDiff = peer.egress.length - sampleLimit;
if (egressDiff > 0) {
// Check if the maximum value is in the beginning.
let i = 0;
while (i < egressDiff && peer.egress[i].value < peer.maxEgress) {
i++;
}
// Remove the old values from the beginning.
peer.egress.splice(0, egressDiff);
if (i < egressDiff) {
// Reset the colors if the maximum value leaves the chart.
setEgressChartAttributes(peer);
}
}
}
});
}
return prev;
};
// styles contains the constant styles of the component.
const styles = {
tableHead: {
height: 'auto',
},
tableRow: {
height: 'auto',
},
tableCell: {
paddingTop: 0,
paddingRight: 5,
paddingBottom: 0,
paddingLeft: 5,
border: 'none',
},
};
export type Props = {
container: Object,
content: NetworkType,
shouldUpdate: Object,
};
type State = {};
// Network renders the network page.
class Network extends Component<Props, State> {
componentDidMount() {
const {container} = this.props;
if (typeof container === 'undefined') {
return;
}
container.scrollTop = 0;
}
formatTime = (t: string) => {
const time = new Date(t);
if (isNaN(time)) {
return '';
}
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);
return `${month}/${date}/${hours}:${minutes}:${seconds}`;
};
copyToClipboard = (id) => (event) => {
event.preventDefault();
navigator.clipboard.writeText(id).then(() => {}, () => {
console.error("Failed to copy node id", id);
});
};
peerTableRow = (ip, id, bundle, peer) => {
const ingressValues = peer.ingress.map(({value}) => ({ingress: value || 0.001}));
const egressValues = peer.egress.map(({value}) => ({egress: -value || -0.001}));
return (
<TableRow key={`known_${ip}_${id}`} style={styles.tableRow}>
<TableCell style={styles.tableCell}>
{peer.active
? <FontAwesomeIcon icon={fasCircle} color='green' />
: <FontAwesomeIcon icon={farCircle} style={commonStyles.light} />
}
</TableCell>
<TableCell style={{fontFamily: 'monospace', cursor: 'copy', ...styles.tableCell, ...commonStyles.light}} onClick={this.copyToClipboard(id)}>
{id.substring(0, 10)}
</TableCell>
<TableCell style={styles.tableCell}>
{bundle.location ? (() => {
const l = bundle.location;
return `${l.country ? l.country : ''}${l.city ? `/${l.city}` : ''}`;
})() : ''}
</TableCell>
<TableCell style={styles.tableCell}>
<AreaChart
width={trafficChartWidth}
height={trafficChartHeight}
data={ingressValues}
margin={{top: 5, right: 5, bottom: 0, left: 5}}
syncId={`peerIngress_${ip}_${id}`}
>
<defs>
<linearGradient id={`ingressGradient_${ip}_${id}`} x1='0' y1='1' x2='0' y2='0'>
{peer.ingressGradient
&& peer.ingressGradient.map(({offset, color}, i) => (
<stop
key={`ingressStop_${ip}_${id}_${i}`}
offset={`${offset}%`}
stopColor={color}
/>
))}
</linearGradient>
</defs>
<Tooltip cursor={false} content={<CustomTooltip tooltip={bytePlotter('Download')} />} />
<YAxis hide scale='sqrt' domain={[0.001, dataMax => Math.max(dataMax, 0)]} />
<Area
dataKey='ingress'
isAnimationActive={false}
type='monotone'
fill={`url(#ingressGradient_${ip}_${id})`}
stroke={peer.ingressGradient[peer.ingressGradient.length - 1].color}
strokeWidth={chartStrokeWidth}
/>
</AreaChart>
<AreaChart
width={trafficChartWidth}
height={trafficChartHeight}
data={egressValues}
margin={{top: 0, right: 5, bottom: 5, left: 5}}
syncId={`peerIngress_${ip}_${id}`}
>
<defs>
<linearGradient id={`egressGradient_${ip}_${id}`} x1='0' y1='1' x2='0' y2='0'>
{peer.egressGradient
&& peer.egressGradient.map(({offset, color}, i) => (
<stop
key={`egressStop_${ip}_${id}_${i}`}
offset={`${offset}%`}
stopColor={color}
/>
))}
</linearGradient>
</defs>
<Tooltip cursor={false} content={<CustomTooltip tooltip={bytePlotter('Upload', multiplier(-1))} />} />
<YAxis hide scale='sqrt' domain={[dataMin => Math.min(dataMin, 0), -0.001]} />
<Area
dataKey='egress'
isAnimationActive={false}
type='monotone'
fill={`url(#egressGradient_${ip}_${id})`}
stroke={peer.egressGradient[0].color}
strokeWidth={chartStrokeWidth}
/>
</AreaChart>
</TableCell>
</TableRow>
);
};
render() {
return (
<Grid container direction='row' justify='space-between'>
<Grid item>
<Table>
<TableHead style={styles.tableHead}>
<TableRow style={styles.tableRow}>
<TableCell style={styles.tableCell} />
<TableCell style={styles.tableCell}>Node ID</TableCell>
<TableCell style={styles.tableCell}>Location</TableCell>
<TableCell style={styles.tableCell}>Traffic</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(this.props.content.peers.bundles).map(([ip, bundle]) => {
if (!bundle.knownPeers || Object.keys(bundle.knownPeers).length < 1) {
return null;
}
return Object.entries(bundle.knownPeers).map(([id, peer]) => {
if (peer.active === false) {
return null;
}
return this.peerTableRow(ip, id, bundle, peer);
});
})}
</TableBody>
<TableBody>
{Object.entries(this.props.content.peers.bundles).map(([ip, bundle]) => {
if (!bundle.knownPeers || Object.keys(bundle.knownPeers).length < 1) {
return null;
}
return Object.entries(bundle.knownPeers).map(([id, peer]) => {
if (peer.active === true) {
return null;
}
return this.peerTableRow(ip, id, bundle, peer);
});
})}
</TableBody>
</Table>
</Grid>
<Grid item>
<Typography variant='subtitle1' gutterBottom>
Connection attempts
</Typography>
<Table>
<TableHead style={styles.tableHead}>
<TableRow style={styles.tableRow}>
<TableCell style={styles.tableCell}>IP</TableCell>
<TableCell style={styles.tableCell}>Location</TableCell>
<TableCell style={styles.tableCell}>Nr</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(this.props.content.peers.bundles).map(([ip, bundle]) => {
if (!bundle.attempts || bundle.attempts.length < 1) {
return null;
}
return (
<TableRow key={`attempt_${ip}`} style={styles.tableRow}>
<TableCell style={styles.tableCell}>{ip}</TableCell>
<TableCell style={styles.tableCell}>
{bundle.location ? (() => {
const l = bundle.location;
return `${l.country ? l.country : ''}${l.city ? `/${l.city}` : ''}`;
})() : ''}
</TableCell>
<TableCell style={styles.tableCell}>
{Object.values(bundle.attempts).length}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Grid>
</Grid>
);
}
}
export default Network;

View file

@ -0,0 +1,122 @@
// @flow
// Copyright 2017 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 withStyles from '@material-ui/core/styles/withStyles';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import Icon from '@material-ui/core/Icon';
import Transition from 'react-transition-group/Transition';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {MENU, DURATION} from '../common';
// styles contains the constant styles of the component.
const styles = {
menu: {
default: {
transition: `margin-left ${DURATION}ms`,
},
transition: {
entered: {marginLeft: -200},
},
},
};
// themeStyles returns the styles generated from the theme for the component.
const themeStyles = theme => ({
list: {
background: theme.palette.grey[900],
},
listItem: {
minWidth: theme.spacing.unit * 7,
},
icon: {
fontSize: theme.spacing.unit * 3,
overflow: 'unset',
},
});
export type Props = {
classes: Object, // injected by withStyles()
opened: boolean,
changeContent: string => void,
};
type State = {}
// SideBar renders the sidebar of the dashboard.
class SideBar extends Component<Props, State> {
shouldComponentUpdate(nextProps: Readonly<Props>, nextState: Readonly<State>, nextContext: any) {
return nextProps.opened !== this.props.opened;
}
// clickOn returns a click event handler function for the given menu item.
clickOn = menu => (event) => {
event.preventDefault();
this.props.changeContent(menu);
};
// menuItems returns the menu items corresponding to the sidebar state.
menuItems = (transitionState) => {
const {classes} = this.props;
const children = [];
MENU.forEach((menu) => {
children.push((
<ListItem button key={menu.id} onClick={this.clickOn(menu.id)} className={classes.listItem}>
<ListItemIcon>
<Icon className={classes.icon}>
<FontAwesomeIcon icon={menu.icon} />
</Icon>
</ListItemIcon>
<ListItemText
primary={menu.title}
style={{
...styles.menu.default,
...styles.menu.transition[transitionState],
padding: 0,
}}
/>
</ListItem>
));
});
return children;
};
// menu renders the list of the menu items.
menu = (transitionState: Object) => (
<div className={this.props.classes.list}>
<List>
{this.menuItems(transitionState)}
</List>
</div>
);
render() {
return (
<Transition mountOnEnter in={this.props.opened} timeout={{enter: DURATION}}>
{this.menu}
</Transition>
);
}
}
export default withStyles(themeStyles)(SideBar);

View file

@ -0,0 +1,23 @@
// Copyright 2017 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/>.
// fa-only-woff-loader removes the .eot, .ttf, .svg dependencies of the FontAwesome library,
// because they produce unused extra blobs.
module.exports = content => content
.replace(/src.*url(?!.*url.*(\.eot)).*(\.eot)[^;]*;/, '')
.replace(/url(?!.*url.*(\.eot)).*(\.eot)[^,]*,/, '')
.replace(/url(?!.*url.*(\.ttf)).*(\.ttf)[^,]*,/, '')
.replace(/,[^,]*url(?!.*url.*(\.svg)).*(\.svg)[^;]*;/, ';');

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en" style="height: 100%">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Go Ethereum Dashboard</title>
<link rel="shortcut icon" type="image/ico" href="https://ethereum.org/favicon.ico" />
<style>
::-webkit-scrollbar {
width: 16px;
}
::-webkit-scrollbar-thumb {
background: #212121;
}
::-webkit-scrollbar-corner {
background: transparent;
}
</style>
</head>
<body style="height: 100%; margin: 0">
<div id="dashboard" style="height: 100%"></div>
<script type="text/javascript" src="bundle.js"></script>
</body>
</html>

View file

@ -0,0 +1,44 @@
// @flow
// Copyright 2017 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 from 'react';
import {render} from 'react-dom';
import MuiThemeProvider from '@material-ui/core/styles/MuiThemeProvider';
import createMuiTheme from '@material-ui/core/styles/createMuiTheme';
import Dashboard from './components/Dashboard';
const theme: Object = createMuiTheme({
// typography: {
// useNextVariants: true,
// },
palette: {
type: 'dark',
},
});
const dashboard = document.getElementById('dashboard');
if (dashboard) {
// Renders the whole dashboard.
render(
<MuiThemeProvider theme={theme}>
<Dashboard />
</MuiThemeProvider>,
dashboard,
);
}

View file

@ -0,0 +1,65 @@
{
"private": true,
"dependencies": {
"@babel/core": "7.3.4",
"@babel/plugin-proposal-class-properties": "7.3.4",
"@babel/plugin-proposal-function-bind": "7.2.0",
"@babel/plugin-transform-flow-strip-types": "7.3.4",
"@babel/preset-env": "7.3.4",
"@babel/preset-react": "^7.0.0",
"@babel/preset-stage-0": "^7.0.0",
"@fortawesome/fontawesome-free-regular": "^5.0.13",
"@fortawesome/fontawesome-svg-core": "^1.2.15",
"@fortawesome/free-regular-svg-icons": "^5.7.2",
"@fortawesome/free-solid-svg-icons": "^5.7.2",
"@fortawesome/react-fontawesome": "^0.1.4",
"@material-ui/core": "3.9.2",
"@material-ui/icons": "3.0.2",
"babel-eslint": "10.0.1",
"babel-loader": "8.0.5",
"classnames": "^2.2.6",
"color-convert": "^2.0.0",
"css-loader": "2.1.1",
"escape-html": "^1.0.3",
"eslint": "5.15.1",
"eslint-config-airbnb": "^17.0.0",
"eslint-loader": "2.1.2",
"eslint-plugin-flowtype": "3.4.2",
"eslint-plugin-import": "2.16.0",
"eslint-plugin-jsx-a11y": "6.2.1",
"eslint-plugin-node": "8.0.1",
"eslint-plugin-promise": "4.0.1",
"eslint-plugin-react": "7.12.4",
"file-loader": "3.0.1",
"flow-bin": "0.94.0",
"flow-bin-loader": "^1.0.3",
"flow-typed": "^2.5.1",
"js-beautify": "1.9.0",
"path": "^0.12.7",
"react": "16.8.4",
"react-dom": "16.8.4",
"react-hot-loader": "4.8.0",
"react-transition-group": "2.6.1",
"recharts": "1.5.0",
"style-loader": "0.23.1",
"terser-webpack-plugin": "^1.2.3",
"url": "^0.11.0",
"url-loader": "1.1.2",
"webpack": "4.29.6",
"webpack-cli": "3.2.3",
"webpack-dashboard": "3.0.0",
"webpack-dev-server": "3.2.1",
"webpack-merge": "4.2.1"
},
"scripts": {
"build": "webpack --config webpack.config.prod.js",
"stats": "webpack --config webpack.config.prod.js --profile --json > stats.json",
"dev": "webpack-dev-server --open --config webpack.config.dev.js",
"dash": "webpack-dashboard -- yarn dev",
"install-flow": "flow-typed install",
"flow": "flow status --show-all-errors",
"eslint": "eslint **/*"
},
"sideEffects": false,
"license": "LGPL-3.0-or-later"
}

View file

@ -0,0 +1,138 @@
// @flow
// Copyright 2017 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/>.
export type Content = {
general: General,
home: Home,
chain: Chain,
txpool: TxPool,
network: Network,
system: System,
logs: Logs,
};
export type ChartEntries = Array<ChartEntry>;
export type ChartEntry = {
value: number,
};
export type General = {
version: ?string,
commit: ?string,
};
export type Home = {
/* TODO (kurkomisi) */
};
export type Chain = {
/* TODO (kurkomisi) */
};
export type TxPool = {
/* TODO (kurkomisi) */
};
export type Network = {
peers: Peers,
diff: Array<PeerEvent>
};
export type PeerEvent = {
ip: string,
id: string,
remove: string,
location: GeoLocation,
connected: Date,
disconnected: Date,
ingress: ChartEntries,
egress: ChartEntries,
activity: string,
};
export type Peers = {
bundles: {[string]: PeerBundle},
};
export type PeerBundle = {
location: GeoLocation,
knownPeers: {[string]: KnownPeer},
attempts: Array<UnknownPeer>,
};
export type KnownPeer = {
connected: Array<Date>,
disconnected: Array<Date>,
ingress: Array<ChartEntries>,
egress: Array<ChartEntries>,
active: boolean,
};
export type UnknownPeer = {
connected: Date,
disconnected: Date,
};
export type GeoLocation = {
country: string,
city: string,
latitude: number,
longitude: number,
};
export type System = {
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 = {
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,
};

View file

@ -0,0 +1,85 @@
// Copyright 2019 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/>.
const path = require('path');
module.exports = {
target: 'web',
entry: {
bundle: './index',
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, ''),
sourceMapFilename: '[file].map',
},
resolve: {
modules: [
'node_modules',
path.resolve(__dirname, 'components'), // import './components/Component' -> import 'Component'
],
extensions: ['.js', '.jsx'],
},
module: {
rules: [
{
test: /\.jsx$/, // regexp for JSX files
exclude: /node_modules/,
use: [ // order: from bottom to top
{
loader: 'babel-loader',
options: {
presets: [ // order: from bottom to top
'@babel/env',
'@babel/react',
],
plugins: [ // order: from top to bottom
'@babel/proposal-function-bind', // instead of stage 0
'@babel/proposal-class-properties', // static defaultProps
'@babel/transform-flow-strip-types',
'react-hot-loader/babel',
],
},
},
// 'eslint-loader', // show errors in the console
],
},
{
test: /\.css$/,
oneOf: [
{
test: /font-awesome/,
use: [
'style-loader',
'css-loader',
path.resolve(__dirname, './fa-only-woff-loader.js'),
],
},
{
use: [
'style-loader',
'css-loader',
],
},
],
},
{
test: /\.woff2?$/, // font-awesome icons
use: 'url-loader',
},
],
},
};

View file

@ -0,0 +1,35 @@
// Copyright 2019 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/>.
const webpack = require('webpack');
const merge = require('webpack-merge');
const WebpackDashboard = require('webpack-dashboard/plugin');
const common = require('./webpack.config.common.js');
module.exports = merge(common, {
mode: 'development',
plugins: [
new WebpackDashboard(),
new webpack.HotModuleReplacementPlugin(),
],
// devtool: 'eval',
devtool: 'source-map',
devServer: {
port: 8081,
hot: true,
compress: true,
},
});

View file

@ -0,0 +1,41 @@
// Copyright 2019 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/>.
const TerserPlugin = require('terser-webpack-plugin');
const merge = require('webpack-merge');
const common = require('./webpack.config.common.js');
module.exports = merge(common, {
mode: 'production',
devtool: 'source-map',
optimization: {
minimize: true,
namedModules: true, // Module names instead of numbers - resolves the large diff problem.
minimizer: [
new TerserPlugin({
cache: true,
parallel: true,
sourceMap: true,
terserOptions: {
output: {
comments: false,
beautify: true,
},
},
}),
],
},
});

7408
dashboard/assets/yarn.lock Normal file

File diff suppressed because it is too large Load diff

41
dashboard/config.go Normal file
View file

@ -0,0 +1,41 @@
// Copyright 2017 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 "time"
// DefaultConfig contains default settings for the dashboard.
var DefaultConfig = Config{
Host: "localhost",
Port: 8080,
Refresh: 5 * time.Second,
}
// Config contains the configuration parameters of the dashboard.
type Config struct {
// Host is the host interface on which to start the dashboard server. If this
// field is empty, no dashboard will be started.
Host string `toml:",omitempty"`
// Port is the TCP port number on which to start the dashboard server. The
// default zero value is/ valid and will pick a port number randomly (useful
// for ephemeral nodes).
Port int `toml:",omitempty"`
// Refresh is the refresh rate of the data updates, the chartEntry will be collected this often.
Refresh time.Duration `toml:",omitempty"`
}

36
dashboard/cpu.go Normal file
View file

@ -0,0 +1,36 @@
// 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/>.
//go:build !windows
// +build !windows
package dashboard
import (
"syscall"
"github.com/cryptoecc/ETH-ECC/log"
)
// getProcessCPUTime retrieves the process' CPU time since program startup.
func getProcessCPUTime() float64 {
var usage syscall.Rusage
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &usage); err != nil {
log.Warn("Failed to retrieve CPU time", "err", err)
return 0
}
return float64(usage.Utime.Sec+usage.Stime.Sec) + float64(usage.Utime.Usec+usage.Stime.Usec)/1000000
}

23
dashboard/cpu_windows.go Normal file
View file

@ -0,0 +1,23 @@
// 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
// getProcessCPUTime returns 0 on Windows as there is no system call to resolve
// the actual process' CPU time.
func getProcessCPUTime() float64 {
return 0
}

280
dashboard/dashboard.go Normal file
View file

@ -0,0 +1,280 @@
// Copyright 2017 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
//go:generate yarn --cwd ./assets install
//go:generate yarn --cwd ./assets build
//go:generate yarn --cwd ./assets js-beautify -f bundle.js.map -r -w 1
//go:generate go-bindata -nometadata -o assets.go -prefix assets -nocompress -pkg dashboard assets/index.html assets/bundle.js assets/bundle.js.map
//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 _bundleJsMap#//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"
"net"
"net/http"
"sync"
"sync/atomic"
"time"
"io"
"github.com/cryptoecc/ETH-ECC/log"
"github.com/cryptoecc/ETH-ECC/p2p"
"github.com/cryptoecc/ETH-ECC/params"
"github.com/cryptoecc/ETH-ECC/rpc"
"github.com/mohae/deepcopy"
"golang.org/x/net/websocket"
)
const (
sampleLimit = 200 // Maximum number of data samples
)
// Dashboard contains the dashboard internals.
type Dashboard struct {
config *Config // Configuration values for the dashboard
listener net.Listener // Network listener listening for dashboard clients
conns map[uint32]*client // Currently live websocket connections
nextConnID uint32 // Next connection id
history *Message // Stored historical data
lock sync.Mutex // Lock protecting the dashboard's internals
sysLock sync.RWMutex // Lock protecting the stored system data
peerLock sync.RWMutex // Lock protecting the stored peer data
logLock sync.RWMutex // Lock protecting the stored log data
geodb *geoDB // geoip database instance for IP to geographical information conversions
logdir string // Directory containing the log files
quit chan chan error // Channel used for graceful exit
wg sync.WaitGroup // Wait group used to close the data collector threads
}
// 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
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, logdir string) *Dashboard {
now := time.Now()
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),
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, sampleLimit),
VirtualMemory: emptyChartEntries(now, sampleLimit),
NetworkIngress: emptyChartEntries(now, sampleLimit),
NetworkEgress: emptyChartEntries(now, sampleLimit),
ProcessCPU: emptyChartEntries(now, sampleLimit),
SystemCPU: emptyChartEntries(now, sampleLimit),
DiskRead: emptyChartEntries(now, sampleLimit),
DiskWrite: emptyChartEntries(now, sampleLimit),
},
},
logdir: logdir,
}
}
// emptyChartEntries returns a ChartEntry array containing limit number of empty samples.
func emptyChartEntries(t time.Time, limit int) ChartEntries {
ce := make(ChartEntries, limit)
for i := 0; i < limit; i++ {
ce[i] = new(ChartEntry)
}
return ce
}
// Protocols implements the node.Service interface.
func (db *Dashboard) Protocols() []p2p.Protocol { return nil }
// APIs implements the node.Service interface.
func (db *Dashboard) APIs() []rpc.API { return nil }
// 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(3)
go db.collectSystemData()
go db.streamLogs()
go db.collectPeerData()
http.HandleFunc("/", db.webHandler)
http.Handle("/api", websocket.Handler(db.apiHandler))
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", db.config.Host, db.config.Port))
if err != nil {
return err
}
db.listener = listener
go http.Serve(listener, nil)
return nil
}
// 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
if err := db.listener.Close(); err != nil {
errs = append(errs, err)
}
// Close the collectors.
errc := make(chan error, 1)
for i := 0; i < 3; i++ {
db.quit <- errc
if err := <-errc; err != nil {
errs = append(errs, err)
}
}
// Close the connections.
db.lock.Lock()
for _, c := range db.conns {
if err := c.conn.Close(); err != nil {
c.logger.Warn("Failed to close connection", "err", err)
}
}
db.lock.Unlock()
// Wait until every goroutine terminates.
db.wg.Wait()
log.Info("Dashboard stopped")
var err error
if len(errs) > 0 {
err = fmt.Errorf("%v", errs)
}
return err
}
// webHandler handles all non-api requests, simply flattening and returning the dashboard website.
func (db *Dashboard) webHandler(w http.ResponseWriter, r *http.Request) {
log.Debug("Request", "URL", r.URL)
path := r.URL.String()
if path == "/" {
path = "/index.html"
}
blob, err := Asset(path[1:])
if err != nil {
log.Warn("Failed to load the asset", "path", path, "err", err)
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Write(blob)
}
// apiHandler handles requests for the dashboard.
func (db *Dashboard) apiHandler(conn *websocket.Conn) {
id := atomic.AddUint32(&db.nextConnID, 1)
client := &client{
conn: conn,
msg: make(chan *Message, 128),
logger: log.New("id", id),
}
done := make(chan struct{})
// Start listening for messages to send.
db.wg.Add(1)
go func() {
defer db.wg.Done()
for {
select {
case <-done:
return
case msg := <-client.msg:
if err := websocket.JSON.Send(client.conn, msg); err != nil {
client.logger.Warn("Failed to send the message", "msg", msg, "err", err)
client.conn.Close()
return
}
}
}
}()
// Send the past data.
db.sysLock.RLock()
db.peerLock.RLock()
db.logLock.RLock()
h := deepcopy.Copy(db.history).(*Message)
db.sysLock.RUnlock()
db.peerLock.RUnlock()
db.logLock.RUnlock()
client.msg <- h
// Start tracking the connection and drop at connection loss.
db.lock.Lock()
db.conns[id] = client
db.lock.Unlock()
defer func() {
db.lock.Lock()
delete(db.conns, id)
db.lock.Unlock()
}()
for {
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
}
if r.Logs != nil {
db.handleLogRequest(r.Logs, client)
}
}
}
// 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:
default:
c.conn.Close()
}
}
db.lock.Unlock()
}

98
dashboard/geoip.go Normal file
View file

@ -0,0 +1,98 @@
// Copyright 2019 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 (
"net"
"time"
"github.com/apilayer/freegeoip"
)
// geoDBInfo contains all the geographical information we could extract based on an IP
// address.
type geoDBInfo struct {
Country struct {
Names struct {
English string `maxminddb:"en" json:"en,omitempty"`
} `maxminddb:"names" json:"names,omitempty"`
} `maxminddb:"country" json:"country,omitempty"`
City struct {
Names struct {
English string `maxminddb:"en" json:"en,omitempty"`
} `maxminddb:"names" json:"names,omitempty"`
} `maxminddb:"city" json:"city,omitempty"`
Location struct {
Latitude float64 `maxminddb:"latitude" json:"latitude,omitempty"`
Longitude float64 `maxminddb:"longitude" json:"longitude,omitempty"`
} `maxminddb:"location" json:"location,omitempty"`
}
// geoLocation contains geographical information.
type geoLocation struct {
Country string `json:"country,omitempty"`
City string `json:"city,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
}
// geoDB represents a geoip database that can be queried for IP to geographical
// information conversions.
type geoDB struct {
geodb *freegeoip.DB
}
// Open creates a new geoip database with an up-to-date database from the internet.
func openGeoDB() (*geoDB, error) {
// Initiate a geoip database to cross reference locations
db, err := freegeoip.OpenURL(freegeoip.MaxMindDB, 24*time.Hour, time.Hour)
if err != nil {
return nil, err
}
// Wait until the database is updated to the latest data
select {
case <-db.NotifyOpen():
case err := <-db.NotifyError():
return nil, err
}
// Assemble and return our custom wrapper
return &geoDB{geodb: db}, nil
}
// Close terminates the database background updater.
func (db *geoDB) close() error {
db.geodb.Close()
return nil
}
// Lookup converts an IP address to a geographical location.
func (db *geoDB) lookup(ip net.IP) *geoDBInfo {
result := new(geoDBInfo)
db.geodb.Lookup(ip, result)
return result
}
// Location retrieves the geographical location of the given IP address.
func (db *geoDB) location(ip string) *geoLocation {
location := db.lookup(net.ParseIP(ip))
return &geoLocation{
Country: location.Country.Names.English,
City: location.City.Names.English,
Latitude: location.Location.Latitude,
Longitude: location.Location.Longitude,
}
}

288
dashboard/log.go Normal file
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/cryptoecc/ETH-ECC/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.logLock.RLock()
if db.history.Logs != nil {
c.msg <- &Message{
Logs: deepcopy.Copy(db.history.Logs).(*LogsMessage),
}
}
db.logLock.RUnlock()
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.logLock.Lock()
db.history.Logs = &LogsMessage{
Source: &LogFile{
Name: fi.Name(),
Last: true,
},
Chunk: emptyChunk,
}
db.logLock.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.logLock.Lock()
db.history.Logs.Source.Name = fi.Name()
db.history.Logs.Chunk = emptyChunk
db.logLock.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.logLock.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.logLock.Unlock()
db.sendToAll(&Message{Logs: l})
case errc = <-db.quit:
break loop
}
}
}

96
dashboard/message.go Normal file
View file

@ -0,0 +1,96 @@
// Copyright 2017 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 (
"encoding/json"
)
type Message struct {
General *GeneralMessage `json:"general,omitempty"`
Home *HomeMessage `json:"home,omitempty"`
Chain *ChainMessage `json:"chain,omitempty"`
TxPool *TxPoolMessage `json:"txpool,omitempty"`
Network *NetworkMessage `json:"network,omitempty"`
System *SystemMessage `json:"system,omitempty"`
Logs *LogsMessage `json:"logs,omitempty"`
}
type ChartEntries []*ChartEntry
type ChartEntry struct {
Value float64 `json:"value"`
}
type GeneralMessage struct {
Version string `json:"version,omitempty"`
Commit string `json:"commit,omitempty"`
}
type HomeMessage struct {
/* TODO (kurkomisi) */
}
type ChainMessage struct {
/* TODO (kurkomisi) */
}
type TxPoolMessage struct {
/* TODO (kurkomisi) */
}
// NetworkMessage contains information about the peers
// organized based on their IP address and node ID.
type NetworkMessage struct {
Peers *peerContainer `json:"peers,omitempty"` // Peer tree.
Diff []*peerEvent `json:"diff,omitempty"` // Events that change the peer tree.
}
// SystemMessage contains the metered system data samples.
type SystemMessage 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"`
}
// LogsMessage wraps up a log chunk. If 'Source' isn't present, the chunk is a stream chunk.
type LogsMessage struct {
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"`
}
// LogsRequest contains the attributes of the log file the client wants to receive.
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.
}

552
dashboard/peers.go Normal file
View file

@ -0,0 +1,552 @@
// Copyright 2019 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 (
"container/list"
"strings"
"time"
"github.com/cryptoecc/ETH-ECC/metrics"
"github.com/cryptoecc/ETH-ECC/log"
"github.com/cryptoecc/ETH-ECC/p2p"
)
const (
eventBufferLimit = 128 // Maximum number of buffered peer events.
knownPeerLimit = 100 // Maximum number of stored peers, which successfully made the handshake.
attemptLimit = 200 // Maximum number of stored peers, which failed to make the handshake.
// eventLimit is the maximum number of the dashboard's custom peer events,
// that are collected between two metering period and sent to the clients
// as one message.
// TODO (kurkomisi): Limit the number of events.
eventLimit = knownPeerLimit << 2
)
// peerContainer contains information about the node's peers. This data structure
// maintains the metered peer data based on the different behaviours of the peers.
//
// Every peer has an IP address, and the peers that manage to make the handshake
// (known peers) have node IDs too. There can appear more peers with the same IP,
// therefore the peer container data structure is a tree consisting of a map of
// maps, where the first key groups the peers by IP, while the second one groups
// them by the node ID. The known peers can be active if their connection is still
// open, or inactive otherwise. The peers failing before the handshake (unknown
// peers) only have IP addresses, so their connection attempts are stored as part
// of the value of the outer map.
//
// Another criteria is to limit the number of metered peers so that
// they don't fill the memory. The selection order is based on the
// peers activity: the peers that are inactive for the longest time
// are thrown first. For the selection a fifo list is used which is
// linked to the bottom of the peer tree in a way that every activity
// of the peer pushes the peer to the end of the list, so the inactive
// ones come to the front. When a peer has some activity, it is removed
// from and reinserted into the list. When the length of the list reaches
// the limit, the first element is removed from the list, as well as from
// the tree.
//
// The active peers have priority over the inactive ones, therefore
// they have their own list. The separation makes it sure that the
// inactive peers are always removed before the active ones.
//
// The peers that don't manage to make handshake are not inserted into the list,
// only their connection attempts are appended to the array belonging to their IP.
// In order to keep the fifo principle, a super array contains the order of the
// attempts, and when the overall count reaches the limit, the earliest attempt is
// removed from the beginning of its array.
//
// This data structure makes it possible to marshal the peer
// history simply by passing it to the JSON marshaler.
type peerContainer struct {
// Bundles is the outer map using the peer's IP address as key.
Bundles map[string]*peerBundle `json:"bundles,omitempty"`
activeCount int // Number of the still connected peers
// inactivePeers contains the peers with closed connection in chronological order.
inactivePeers *list.List
// attemptOrder is the super array containing the IP addresses, from which
// the peers attempted to connect then failed before/during the handshake.
// Its values are appended in chronological order, which means that the
// oldest attempt is at the beginning of the array. When the first element
// is removed, the first element of the related bundle's attempt array is
// removed too, ensuring that always the latest attempts are stored.
attemptOrder []string
// geodb is the geoip database used to retrieve the peers' geographical location.
geodb *geoDB
}
// newPeerContainer returns a new instance of the peer container.
func newPeerContainer(geodb *geoDB) *peerContainer {
return &peerContainer{
Bundles: make(map[string]*peerBundle),
inactivePeers: list.New(),
attemptOrder: make([]string, 0, attemptLimit),
geodb: geodb,
}
}
// bundle inserts a new peer bundle into the map, if the peer belonging
// to the given IP wasn't metered so far. In this case retrieves the location of
// the IP address from the database and creates a corresponding peer event.
// Returns the bundle belonging to the given IP and the events occurring during
// the initialization.
func (pc *peerContainer) bundle(ip string) (*peerBundle, []*peerEvent) {
var events []*peerEvent
if _, ok := pc.Bundles[ip]; !ok {
location := pc.geodb.location(ip)
events = append(events, &peerEvent{
IP: ip,
Location: location,
})
pc.Bundles[ip] = &peerBundle{
Location: location,
KnownPeers: make(map[string]*knownPeer),
}
}
return pc.Bundles[ip], events
}
// extendKnown handles the events of the successfully connected peers.
// Returns the events occurring during the extension.
func (pc *peerContainer) extendKnown(event *peerEvent) []*peerEvent {
bundle, events := pc.bundle(event.IP)
peer, peerEvents := bundle.knownPeer(event.IP, event.ID)
events = append(events, peerEvents...)
// Append the connect and the disconnect events to
// the corresponding arrays keeping the limit.
switch {
case event.Connected != nil:
peer.Connected = append(peer.Connected, event.Connected)
if first := len(peer.Connected) - sampleLimit; first > 0 {
peer.Connected = peer.Connected[first:]
}
peer.Active = true
events = append(events, &peerEvent{
Activity: Active,
IP: peer.ip,
ID: peer.id,
})
pc.activeCount++
if peer.listElement != nil {
_ = pc.inactivePeers.Remove(peer.listElement)
peer.listElement = nil
}
case event.Disconnected != nil:
peer.Disconnected = append(peer.Disconnected, event.Disconnected)
if first := len(peer.Disconnected) - sampleLimit; first > 0 {
peer.Disconnected = peer.Disconnected[first:]
}
peer.Active = false
events = append(events, &peerEvent{
Activity: Inactive,
IP: peer.ip,
ID: peer.id,
})
pc.activeCount--
if peer.listElement != nil {
// If the peer is already in the list, remove and reinsert it.
_ = pc.inactivePeers.Remove(peer.listElement)
}
// Insert the peer into the list.
peer.listElement = pc.inactivePeers.PushBack(peer)
}
for pc.inactivePeers.Len() > 0 && pc.activeCount+pc.inactivePeers.Len() > knownPeerLimit {
// While the count of the known peers is greater than the limit,
// remove the first element from the inactive peer list and from the map.
if removedPeer, ok := pc.inactivePeers.Remove(pc.inactivePeers.Front()).(*knownPeer); ok {
events = append(events, pc.removeKnown(removedPeer.ip, removedPeer.id)...)
} else {
log.Warn("Failed to parse the removed peer")
}
}
if pc.activeCount > knownPeerLimit {
log.Warn("Number of active peers is greater than the limit")
}
return events
}
// handleAttempt handles the events of the peers failing before/during the handshake.
// Returns the events occurring during the extension.
func (pc *peerContainer) handleAttempt(event *peerEvent) []*peerEvent {
bundle, events := pc.bundle(event.IP)
bundle.Attempts = append(bundle.Attempts, &peerAttempt{
Connected: *event.Connected,
Disconnected: *event.Disconnected,
})
pc.attemptOrder = append(pc.attemptOrder, event.IP)
for len(pc.attemptOrder) > attemptLimit {
// While the length of the connection attempt order array is greater
// than the limit, remove the first element from the involved peer's
// array and also from the super array.
events = append(events, pc.removeAttempt(pc.attemptOrder[0])...)
pc.attemptOrder = pc.attemptOrder[1:]
}
return events
}
// peerBundle contains the peers belonging to a given IP address.
type peerBundle struct {
// Location contains the geographical location based on the bundle's IP address.
Location *geoLocation `json:"location,omitempty"`
// KnownPeers is the inner map of the metered peer
// maintainer data structure using the node ID as key.
KnownPeers map[string]*knownPeer `json:"knownPeers,omitempty"`
// Attempts contains the failed connection attempts of the
// peers belonging to a given IP address in chronological order.
Attempts []*peerAttempt `json:"attempts,omitempty"`
}
// removeKnown removes the known peer belonging to the
// given IP address and node ID from the peer tree.
func (pc *peerContainer) removeKnown(ip, id string) (events []*peerEvent) {
// TODO (kurkomisi): Remove peers that don't have traffic samples anymore.
if bundle, ok := pc.Bundles[ip]; ok {
if _, ok := bundle.KnownPeers[id]; ok {
events = append(events, &peerEvent{
Remove: RemoveKnown,
IP: ip,
ID: id,
})
delete(bundle.KnownPeers, id)
} else {
log.Warn("No peer to remove", "ip", ip, "id", id)
}
if len(bundle.KnownPeers) < 1 && len(bundle.Attempts) < 1 {
events = append(events, &peerEvent{
Remove: RemoveBundle,
IP: ip,
})
delete(pc.Bundles, ip)
}
} else {
log.Warn("No bundle to remove", "ip", ip)
}
return events
}
// removeAttempt removes the peer attempt belonging to the
// given IP address and node ID from the peer tree.
func (pc *peerContainer) removeAttempt(ip string) (events []*peerEvent) {
if bundle, ok := pc.Bundles[ip]; ok {
if len(bundle.Attempts) > 0 {
events = append(events, &peerEvent{
Remove: RemoveAttempt,
IP: ip,
})
bundle.Attempts = bundle.Attempts[1:]
}
if len(bundle.Attempts) < 1 && len(bundle.KnownPeers) < 1 {
events = append(events, &peerEvent{
Remove: RemoveBundle,
IP: ip,
})
delete(pc.Bundles, ip)
}
}
return events
}
// knownPeer inserts a new peer into the map, if the peer belonging
// to the given IP address and node ID wasn't metered so far. Returns the peer
// belonging to the given IP and ID as well as the events occurring during the
// initialization.
func (bundle *peerBundle) knownPeer(ip, id string) (*knownPeer, []*peerEvent) {
var events []*peerEvent
if _, ok := bundle.KnownPeers[id]; !ok {
now := time.Now()
ingress := emptyChartEntries(now, sampleLimit)
egress := emptyChartEntries(now, sampleLimit)
events = append(events, &peerEvent{
IP: ip,
ID: id,
Ingress: append([]*ChartEntry{}, ingress...),
Egress: append([]*ChartEntry{}, egress...),
})
bundle.KnownPeers[id] = &knownPeer{
ip: ip,
id: id,
Ingress: ingress,
Egress: egress,
}
}
return bundle.KnownPeers[id], events
}
// knownPeer contains the metered data of a particular peer.
type knownPeer struct {
// Connected contains the timestamps of the peer's connection events.
Connected []*time.Time `json:"connected,omitempty"`
// Disconnected contains the timestamps of the peer's disconnection events.
Disconnected []*time.Time `json:"disconnected,omitempty"`
// Ingress and Egress contain the peer's traffic samples, which are collected
// periodically from the metrics registry.
//
// A peer can connect multiple times, and we want to visualize the time
// passed between two connections, so after the first connection a 0 value
// is appended to the traffic arrays even if the peer is inactive until the
// peer is removed.
Ingress ChartEntries `json:"ingress,omitempty"`
Egress ChartEntries `json:"egress,omitempty"`
Active bool `json:"active"` // Denotes if the peer is still connected.
listElement *list.Element // Pointer to the peer element in the list.
ip, id string // The IP and the ID by which the peer can be accessed in the tree.
prevIngress float64
prevEgress float64
}
// peerAttempt contains a failed peer connection attempt's attributes.
type peerAttempt struct {
// Connected contains the timestamp of the connection attempt's moment.
Connected time.Time `json:"connected"`
// Disconnected contains the timestamp of the
// moment when the connection attempt failed.
Disconnected time.Time `json:"disconnected"`
}
type RemovedPeerType string
type ActivityType string
const (
RemoveKnown RemovedPeerType = "known"
RemoveAttempt RemovedPeerType = "attempt"
RemoveBundle RemovedPeerType = "bundle"
Active ActivityType = "active"
Inactive ActivityType = "inactive"
)
// peerEvent contains the attributes of a peer event.
type peerEvent struct {
IP string `json:"ip,omitempty"` // IP address of the peer.
ID string `json:"id,omitempty"` // Node ID of the peer.
Remove RemovedPeerType `json:"remove,omitempty"` // Type of the peer that is to be removed.
Location *geoLocation `json:"location,omitempty"` // Geographical location of the peer.
Connected *time.Time `json:"connected,omitempty"` // Timestamp of the connection moment.
Disconnected *time.Time `json:"disconnected,omitempty"` // Timestamp of the disonnection moment.
Ingress ChartEntries `json:"ingress,omitempty"` // Ingress samples.
Egress ChartEntries `json:"egress,omitempty"` // Egress samples.
Activity ActivityType `json:"activity,omitempty"` // Connection status change.
}
// trafficMap is a container for the periodically collected peer traffic.
type trafficMap map[string]map[string]float64
// insert inserts a new value to the traffic map. Overwrites
// the value at the given ip and id if that already exists.
func (m *trafficMap) insert(ip, id string, val float64) {
if _, ok := (*m)[ip]; !ok {
(*m)[ip] = make(map[string]float64)
}
(*m)[ip][id] = val
}
// collectPeerData gathers data about the peers and sends it to the clients.
func (db *Dashboard) collectPeerData() {
defer db.wg.Done()
// Open the geodb database for IP to geographical information conversions.
var err error
db.geodb, err = openGeoDB()
if err != nil {
log.Warn("Failed to open geodb", "err", err)
return
}
defer db.geodb.close()
peerCh := make(chan p2p.MeteredPeerEvent, eventBufferLimit) // Peer event channel.
subPeer := p2p.SubscribeMeteredPeerEvent(peerCh) // Subscribe to peer events.
defer subPeer.Unsubscribe() // Unsubscribe at the end.
ticker := time.NewTicker(db.config.Refresh)
defer ticker.Stop()
type registryFunc func(name string, i interface{})
type collectorFunc func(traffic *trafficMap) registryFunc
// trafficCollector generates a function that can be passed to
// the prefixed peer registry in order to collect the metered
// traffic data from each peer meter.
trafficCollector := func(prefix string) collectorFunc {
// This part makes is possible to collect the
// traffic data into a map from outside.
return func(traffic *trafficMap) registryFunc {
// The function which can be passed to the registry.
return func(name string, i interface{}) {
if m, ok := i.(metrics.Meter); ok {
// The name of the meter has the format: <common traffic prefix><IP>/<ID>
if k := strings.Split(strings.TrimPrefix(name, prefix), "/"); len(k) == 2 {
traffic.insert(k[0], k[1], float64(m.Count()))
} else {
log.Warn("Invalid meter name", "name", name, "prefix", prefix)
}
} else {
log.Warn("Invalid meter type", "name", name)
}
}
}
}
collectIngress := trafficCollector(p2p.MetricsInboundTraffic + "/")
collectEgress := trafficCollector(p2p.MetricsOutboundTraffic + "/")
peers := newPeerContainer(db.geodb)
db.peerLock.Lock()
db.history.Network = &NetworkMessage{
Peers: peers,
}
db.peerLock.Unlock()
// newPeerEvents contains peer events, which trigger operations that
// will be executed on the peer tree after a metering period.
newPeerEvents := make([]*peerEvent, 0, eventLimit)
ingress, egress := new(trafficMap), new(trafficMap)
*ingress, *egress = make(trafficMap), make(trafficMap)
for {
select {
case event := <-peerCh:
now := time.Now()
switch event.Type {
case p2p.PeerConnected:
connected := now.Add(-event.Elapsed)
newPeerEvents = append(newPeerEvents, &peerEvent{
IP: event.IP.String(),
ID: event.ID.String(),
Connected: &connected,
})
case p2p.PeerDisconnected:
ip, id := event.IP.String(), event.ID.String()
newPeerEvents = append(newPeerEvents, &peerEvent{
IP: ip,
ID: id,
Disconnected: &now,
})
// The disconnect event comes with the last metered traffic count,
// because after the disconnection the peer's meter is removed
// from the registry. It can happen, that between two metering
// period the same peer disconnects multiple times, and appending
// all the samples to the traffic arrays would shift the metering,
// so only the last metering is stored, overwriting the previous one.
ingress.insert(ip, id, float64(event.Ingress))
egress.insert(ip, id, float64(event.Egress))
case p2p.PeerHandshakeFailed:
connected := now.Add(-event.Elapsed)
newPeerEvents = append(newPeerEvents, &peerEvent{
IP: event.IP.String(),
Connected: &connected,
Disconnected: &now,
})
default:
log.Error("Unknown metered peer event type", "type", event.Type)
}
case <-ticker.C:
// Collect the traffic samples from the registry.
p2p.PeerIngressRegistry.Each(collectIngress(ingress))
p2p.PeerEgressRegistry.Each(collectEgress(egress))
// Protect 'peers', because it is part of the history.
db.peerLock.Lock()
var diff []*peerEvent
for i := 0; i < len(newPeerEvents); i++ {
if newPeerEvents[i].IP == "" {
log.Warn("Peer event without IP", "event", *newPeerEvents[i])
continue
}
diff = append(diff, newPeerEvents[i])
// There are two main branches of peer events coming from the event
// feed, one belongs to the known peers, one to the unknown peers.
// If the event has node ID, it belongs to a known peer, otherwise
// to an unknown one, which is considered as connection attempt.
//
// The extension can produce additional peer events, such
// as remove, location and initial samples events.
if newPeerEvents[i].ID == "" {
diff = append(diff, peers.handleAttempt(newPeerEvents[i])...)
continue
}
diff = append(diff, peers.extendKnown(newPeerEvents[i])...)
}
// Update the peer tree using the traffic maps.
for ip, bundle := range peers.Bundles {
for id, peer := range bundle.KnownPeers {
// Value is 0 if the traffic map doesn't have the
// entry corresponding to the given IP and ID.
curIngress, curEgress := (*ingress)[ip][id], (*egress)[ip][id]
deltaIngress, deltaEgress := curIngress, curEgress
if deltaIngress >= peer.prevIngress {
deltaIngress -= peer.prevIngress
}
if deltaEgress >= peer.prevEgress {
deltaEgress -= peer.prevEgress
}
peer.prevIngress, peer.prevEgress = curIngress, curEgress
i := &ChartEntry{
Value: deltaIngress,
}
e := &ChartEntry{
Value: deltaEgress,
}
peer.Ingress = append(peer.Ingress, i)
peer.Egress = append(peer.Egress, e)
if first := len(peer.Ingress) - sampleLimit; first > 0 {
peer.Ingress = peer.Ingress[first:]
}
if first := len(peer.Egress) - sampleLimit; first > 0 {
peer.Egress = peer.Egress[first:]
}
// Creating the traffic sample events.
diff = append(diff, &peerEvent{
IP: ip,
ID: id,
Ingress: ChartEntries{i},
Egress: ChartEntries{e},
})
}
}
db.peerLock.Unlock()
if len(diff) > 0 {
db.sendToAll(&Message{Network: &NetworkMessage{
Diff: diff,
}})
}
// Clear the traffic maps, and the event array,
// prepare them for the next metering.
*ingress, *egress = make(trafficMap), make(trafficMap)
newPeerEvents = newPeerEvents[:0]
case err := <-subPeer.Err():
log.Warn("Peer subscription error", "err", err)
return
case errc := <-db.quit:
errc <- nil
return
}
}
}

146
dashboard/system.go Normal file
View file

@ -0,0 +1,146 @@
// Copyright 2019 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 (
"runtime"
"time"
"github.com/cryptoecc/ETH-ECC/metrics"
"github.com/cryptoecc/ETH-ECC/p2p"
"github.com/elastic/gosigar"
)
// meterCollector returns a function, which retrieves the count of a specific meter.
func meterCollector(name string) func() int64 {
if meter := metrics.Get(name); meter != nil {
m := meter.(metrics.Meter)
return func() int64 {
return m.Count()
}
}
return func() int64 {
return 0
}
}
// collectSystemData gathers data about the system and sends it to the clients.
func (db *Dashboard) collectSystemData() {
defer db.wg.Done()
systemCPUUsage := gosigar.Cpu{}
systemCPUUsage.Get()
var (
mem runtime.MemStats
collectNetworkIngress = meterCollector(p2p.MetricsInboundTraffic)
collectNetworkEgress = meterCollector(p2p.MetricsOutboundTraffic)
collectDiskRead = meterCollector("eth/db/chaindata/disk/read")
collectDiskWrite = meterCollector("eth/db/chaindata/disk/write")
prevNetworkIngress = collectNetworkIngress()
prevNetworkEgress = collectNetworkEgress()
prevProcessCPUTime = getProcessCPUTime()
prevSystemCPUUsage = systemCPUUsage
prevDiskRead = collectDiskRead()
prevDiskWrite = collectDiskWrite()
frequency = float64(db.config.Refresh / time.Second)
numCPU = float64(runtime.NumCPU())
)
for {
select {
case errc := <-db.quit:
errc <- nil
return
case <-time.After(db.config.Refresh):
systemCPUUsage.Get()
var (
curNetworkIngress = collectNetworkIngress()
curNetworkEgress = collectNetworkEgress()
curProcessCPUTime = getProcessCPUTime()
curSystemCPUUsage = systemCPUUsage
curDiskRead = collectDiskRead()
curDiskWrite = collectDiskWrite()
deltaNetworkIngress = float64(curNetworkIngress - prevNetworkIngress)
deltaNetworkEgress = float64(curNetworkEgress - prevNetworkEgress)
deltaProcessCPUTime = curProcessCPUTime - prevProcessCPUTime
deltaSystemCPUUsage = curSystemCPUUsage.Delta(prevSystemCPUUsage)
deltaDiskRead = curDiskRead - prevDiskRead
deltaDiskWrite = curDiskWrite - prevDiskWrite
)
prevNetworkIngress = curNetworkIngress
prevNetworkEgress = curNetworkEgress
prevProcessCPUTime = curProcessCPUTime
prevSystemCPUUsage = curSystemCPUUsage
prevDiskRead = curDiskRead
prevDiskWrite = curDiskWrite
runtime.ReadMemStats(&mem)
activeMemory := &ChartEntry{
Value: float64(mem.Alloc) / frequency,
}
virtualMemory := &ChartEntry{
Value: float64(mem.Sys) / frequency,
}
networkIngress := &ChartEntry{
Value: deltaNetworkIngress / frequency,
}
networkEgress := &ChartEntry{
Value: deltaNetworkEgress / frequency,
}
processCPU := &ChartEntry{
Value: deltaProcessCPUTime / frequency / numCPU * 100,
}
systemCPU := &ChartEntry{
Value: float64(deltaSystemCPUUsage.Sys+deltaSystemCPUUsage.User) / frequency / numCPU,
}
diskRead := &ChartEntry{
Value: float64(deltaDiskRead) / frequency,
}
diskWrite := &ChartEntry{
Value: float64(deltaDiskWrite) / frequency,
}
db.sysLock.Lock()
sys := db.history.System
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.sysLock.Unlock()
db.sendToAll(&Message{
System: &SystemMessage{
ActiveMemory: ChartEntries{activeMemory},
VirtualMemory: ChartEntries{virtualMemory},
NetworkIngress: ChartEntries{networkIngress},
NetworkEgress: ChartEntries{networkEgress},
ProcessCPU: ChartEntries{processCPU},
SystemCPU: ChartEntries{systemCPU},
DiskRead: ChartEntries{diskRead},
DiskWrite: ChartEntries{diskWrite},
},
})
}
}
}