mirror of
https://github.com/ethereum/go-ethereum.git
synced 2026-08-20 02:42:27 +00:00
dashboard: maintain legacy
This commit is contained in:
parent
22965ba57e
commit
50a300ebcb
34 changed files with 50211 additions and 0 deletions
58
dashboard/README.md
Normal file
58
dashboard/README.md
Normal 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
38665
dashboard/assets.go
Normal file
File diff suppressed because one or more lines are too long
3
dashboard/assets/.eslintignore
Normal file
3
dashboard/assets/.eslintignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/* #ignored by default
|
||||
flow-typed/*
|
||||
bundle.js
|
||||
81
dashboard/assets/.eslintrc
Normal file
81
dashboard/assets/.eslintrc
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
dashboard/assets/.flowconfig
Normal file
11
dashboard/assets/.flowconfig
Normal 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
|
||||
92
dashboard/assets/common.jsx
Normal file
92
dashboard/assets/common.jsx
Normal 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];
|
||||
63
dashboard/assets/components/Body.jsx
Normal file
63
dashboard/assets/components/Body.jsx
Normal 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;
|
||||
57
dashboard/assets/components/ChartRow.jsx
Normal file
57
dashboard/assets/components/ChartRow.jsx
Normal 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;
|
||||
84
dashboard/assets/components/CustomTooltip.jsx
Normal file
84
dashboard/assets/components/CustomTooltip.jsx
Normal 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;
|
||||
258
dashboard/assets/components/Dashboard.jsx
Normal file
258
dashboard/assets/components/Dashboard.jsx
Normal 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));
|
||||
211
dashboard/assets/components/Footer.jsx
Normal file
211
dashboard/assets/components/Footer.jsx
Normal 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);
|
||||
81
dashboard/assets/components/Header.jsx
Normal file
81
dashboard/assets/components/Header.jsx
Normal 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);
|
||||
327
dashboard/assets/components/Logs.jsx
Normal file
327
dashboard/assets/components/Logs.jsx
Normal 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 ';
|
||||
color = '#4c8f0f';
|
||||
break;
|
||||
case 'warn':
|
||||
lvl = 'WARN ';
|
||||
color = '#b79a22';
|
||||
break;
|
||||
case 'error':
|
||||
case 'eror':
|
||||
lvl = 'ERROR';
|
||||
color = '#754b70';
|
||||
break;
|
||||
case 'crit':
|
||||
lvl = 'CRIT ';
|
||||
color = '#ce3c23';
|
||||
break;
|
||||
default:
|
||||
lvl = '';
|
||||
}
|
||||
const time = new Date(t);
|
||||
if (lvl === '' || !(time instanceof Date) || isNaN(time) || typeof msg !== 'string' || !Array.isArray(ctx)) {
|
||||
content += '<span style="color:#ce3c23">Invalid log record</span><br />';
|
||||
return;
|
||||
}
|
||||
if (ctx.length > 0) {
|
||||
msg += ' '.repeat(Math.max(40 - msg.length, 0));
|
||||
}
|
||||
const month = `0${time.getMonth() + 1}`.slice(-2);
|
||||
const date = `0${time.getDate()}`.slice(-2);
|
||||
const hours = `0${time.getHours()}`.slice(-2);
|
||||
const minutes = `0${time.getMinutes()}`.slice(-2);
|
||||
const seconds = `0${time.getSeconds()}`.slice(-2);
|
||||
content += `<span style="color:${color}">${lvl}</span>[${month}-${date}|${hours}:${minutes}:${seconds}] ${msg}`;
|
||||
|
||||
for (let i = 0; i < ctx.length; i += 2) {
|
||||
const key = escapeHtml(ctx[i]);
|
||||
const val = escapeHtml(ctx[i + 1]);
|
||||
let padding = fieldPadding.get(key);
|
||||
if (typeof padding !== 'number' || padding < val.length) {
|
||||
padding = val.length;
|
||||
fieldPadding.set(key, padding);
|
||||
}
|
||||
let p = '';
|
||||
if (i < ctx.length - 2) {
|
||||
p = ' '.repeat(padding - val.length);
|
||||
}
|
||||
content += ` <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;
|
||||
144
dashboard/assets/components/Main.jsx
Normal file
144
dashboard/assets/components/Main.jsx
Normal 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);
|
||||
529
dashboard/assets/components/Network.jsx
Normal file
529
dashboard/assets/components/Network.jsx
Normal 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;
|
||||
122
dashboard/assets/components/SideBar.jsx
Normal file
122
dashboard/assets/components/SideBar.jsx
Normal 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);
|
||||
23
dashboard/assets/fa-only-woff-loader.js
Normal file
23
dashboard/assets/fa-only-woff-loader.js
Normal 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)[^;]*;/, ';');
|
||||
26
dashboard/assets/index.html
Normal file
26
dashboard/assets/index.html
Normal 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>
|
||||
44
dashboard/assets/index.jsx
Normal file
44
dashboard/assets/index.jsx
Normal 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,
|
||||
);
|
||||
}
|
||||
65
dashboard/assets/package.json
Normal file
65
dashboard/assets/package.json
Normal 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"
|
||||
}
|
||||
138
dashboard/assets/types/content.jsx
Normal file
138
dashboard/assets/types/content.jsx
Normal 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,
|
||||
};
|
||||
85
dashboard/assets/webpack.config.common.js
Normal file
85
dashboard/assets/webpack.config.common.js
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
35
dashboard/assets/webpack.config.dev.js
Normal file
35
dashboard/assets/webpack.config.dev.js
Normal 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,
|
||||
},
|
||||
});
|
||||
41
dashboard/assets/webpack.config.prod.js
Normal file
41
dashboard/assets/webpack.config.prod.js
Normal 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
7408
dashboard/assets/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
41
dashboard/config.go
Normal file
41
dashboard/config.go
Normal 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
36
dashboard/cpu.go
Normal 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
23
dashboard/cpu_windows.go
Normal 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
280
dashboard/dashboard.go
Normal 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
98
dashboard/geoip.go
Normal 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
288
dashboard/log.go
Normal 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
96
dashboard/message.go
Normal 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
552
dashboard/peers.go
Normal 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
146
dashboard/system.go
Normal 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},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue