cmd, dashboard, metrics: initial proof of concept dashboard

This commit is contained in:
Kurkó Mihály 2017-11-14 15:36:32 +02:00
parent 523ff69b23
commit c48c55fedb
19 changed files with 1794 additions and 1488 deletions

2
.gitignore vendored
View file

@ -36,3 +36,5 @@ profile.cov
# dashboard # dashboard
/dashboard/assets/node_modules /dashboard/assets/node_modules
/dashboard/assets/stats.json
/dashboard/assets/public/bundle.js

View file

@ -206,7 +206,7 @@ var (
} }
DashboardAssetsFlag = cli.StringFlag{ DashboardAssetsFlag = cli.StringFlag{
Name: "dashboard.assets", Name: "dashboard.assets",
Usage: "Developer flag to serve the dashboard from the local file system (default: \"\")", Usage: "Developer flag to serve the dashboard from the local file system",
Value: dashboard.DefaultConfig.Assets, Value: dashboard.DefaultConfig.Assets,
} }
// Ethash settings // Ethash settings

View file

@ -1,55 +1,41 @@
## Go Ethereum Dashboard ## Go Ethereum Dashboard
### Description
The dashboard is a data visualizer integrated into geth, intended to collect and visualize useful information of an Ethereum node. The dashboard is a data visualizer integrated into geth, intended to collect and visualize useful information of an Ethereum node.
The dashboard consists of two parts: Consists of two parts:
* The server listens to connections, collects data with a given refresh rate, and updates the dashboards through the opened connections. * The client visualizes the collected data.
* The client waits for update messages, updates the content and tries to reconnect on connection loss. * The server collects the data, and updates the clients.
### Users The client's UI uses [React][React] with JSX syntax, which is validated by the [ESLint][ESLint] linter
#### Installation steps 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].
1. `cd .../go-ethereum/` ### Install and run the server
1. `go install -v ./cmd/geth`
1. Run the server with `geth --rinkeby --dashboard --vmodule=dashboard=5 --metrics`.
1. Enter `localhost:8080` (or change the configuration).
### Developers
The client's UI is maintained by [React][React], the facebook's JavaScript library.
The [ESLint pluggable linting utility][ESLint] validates the JSX syntax mostly according to the [Airbnb React/JSX Style Guide][Airbnb], the style is defined in the `.eslintrc` configuration file.
[Webpack module bundler][Webpack] is used for bundling the resources in order to gain cost efficiency and maintainability.
The resources are bundled into a single JS file (`bundle.js`), which is referenced from the main html file.
This JS file also takes part in the `assets.go`.
[Node.js][Node.js] is used for installing the necessary dependencies for the module bundler.
#### Installation steps
_Module bundler_
1. `cd .../go-ethereum/dashboard/assets/`
1. `npm install`
1. `./node_modules/.bin/webpack` // check out `webpack.config.js`
* Optionally use `--watch` to automatically bundle the resources on change.
_Server_
1. Bundle the resources.
1. `cd .../go-ethereum/`.
1. `go generate ./dashboard && go install -v ./cmd/geth`. 1. `go generate ./dashboard && go install -v ./cmd/geth`.
1. Run the server with `geth --rinkeby --dashboard --vmodule=dashboard=5 --metrics console`. 1. `geth --dashboard --vmodule=dashboard=5`.
* Optionally use `--dashboard.assets=<path>` to set the assets' path (e.g. `--dashboard.assets=".../go-ethereum/dashboard/assets/public"`).
Using this flag it is enough to only bundle the resources with webpack and refresh the page.
There is no need for stopping the server and regenerating the `assets.go` on every change of the UI.
1. Enter `localhost:8080` (or change the configuration).
#### Tools During the development use the `--dashboard.assets=<absolute path>` flag to set the assets' path
[Webpack][Webpack] offers great tools for visualizing the bundle's dependency tree and space usage. (e.g. `geth --rinkeby --dashboard --dashboard.assets="<path>/dashboard/assets/public" --vmodule=dashboard=5 console`).
This way there is no need to stop and regenerate the server to modify the client.
* Generate the bundle's profile by running `webpack --profile --json > stats.json` ### Install the module bundler
1. `cd dashboard/assets`
1. `npm install`
### Bundle the resources
1. `cd dashboard/assets`
1. `./node_modules/.bin/webpack`
1. Enter `localhost:8080` to check the result
### Have fun
[Webpack][Webpack] offers handy tools for visualizing the bundle's dependency tree and space usage.
* Generate the bundle's profile running `webpack --profile --json > stats.json`
* For the _dependency tree_ go to [Webpack Analyze][WA], and import `stats.json` * 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` * For the _space usage_ go to [Webpack Visualizer][WV], and import `stats.json`

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,19 @@
// 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 // React syntax style mostly according to https://github.com/airbnb/javascript/tree/master/react
{ {
"plugins": [ "plugins": [
@ -26,6 +42,11 @@
"react/self-closing-comp": 2, "react/self-closing-comp": 2,
"react/jsx-no-bind": 2, "react/jsx-no-bind": 2,
"react/require-render-return": 2, "react/require-render-return": 2,
"react/no-is-mounted": 2 "react/no-is-mounted": 2,
"key-spacing": ["error", {"align": {
"beforeColon": false,
"afterColon": true,
"on": "value"
}}]
} }
} }

View file

@ -1,21 +1,36 @@
// 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/>.
// isNullOrUndefined returns true if the given variable is null or undefined. // isNullOrUndefined returns true if the given variable is null or undefined.
export const isNullOrUndefined = variable => variable === null || typeof variable === 'undefined'; export const isNullOrUndefined = variable => variable === null || typeof variable === 'undefined';
// defaultZero returns 0 if the given element is null or undefined, otherwise returns the given element. export const LIMIT = {
export const defaultZero = elem => isNullOrUndefined(elem) ? 0 : elem; memory: 200, // Maximum number of memory data samples.
traffic: 200, // Maximum number of traffic data samples.
export const MEMORY_SAMPLE_LIMIT = 200; // Maximum number of memory data samples. log: 200, // Maximum number of logs.
export const TRAFFIC_SAMPLE_LIMIT = 200; // Maximum number of traffic data samples. };
// The sidebar menu and the main content are rendered based on these elements. // The sidebar menu and the main content are rendered based on these elements.
export const TAGS = (() => { export const TAGS = (() => {
const T = { const T = {
home: { title: "Home", }, home: { title: "Home", },
logs: { title: "Logs", }, chain: { title: "Chain", },
networking: { title: "Networking", }, transactions: { title: "Transactions", },
txpool: { title: "Txpool", }, network: { title: "Network", },
blockchain: { title: "Blockchain", },
system: { title: "System", }, system: { title: "System", },
logs: { title: "Logs", },
}; };
// Using the key is circumstantial in some cases, so it is better to insert it also as a value. // Using the key is circumstantial in some cases, so it is better to insert it also as a value.
// This way the mistyping is prevented. // This way the mistyping is prevented.
@ -25,5 +40,13 @@ export const TAGS = (() => {
return T; return T;
})(); })();
export const DATA_KEYS = (() => {
const DK = {};
["memory", "traffic", "logs"].map(key => {
DK[key] = key;
});
return DK;
})();
// Temporary - taken from Material-UI // Temporary - taken from Material-UI
export const DRAWER_WIDTH = 240; export const DRAWER_WIDTH = 240;

View file

@ -1,3 +1,19 @@
// 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 React, {Component} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {withStyles} from 'material-ui/styles'; import {withStyles} from 'material-ui/styles';
@ -5,7 +21,7 @@ import {withStyles} from 'material-ui/styles';
import SideBar from './SideBar.jsx'; import SideBar from './SideBar.jsx';
import Header from './Header.jsx'; import Header from './Header.jsx';
import Main from "./Main.jsx"; import Main from "./Main.jsx";
import {isNullOrUndefined, defaultZero, MEMORY_SAMPLE_LIMIT, TAGS} from "./Common.jsx"; import {isNullOrUndefined, LIMIT, TAGS, DATA_KEYS,} from "./Common.jsx";
// Styles for the Dashboard component. // Styles for the Dashboard component.
const styles = theme => ({ const styles = theme => ({
@ -14,12 +30,11 @@ const styles = theme => ({
display: 'flex', display: 'flex',
width: '100%', width: '100%',
height: '100%', height: '100%',
background: '#303030', background: theme.palette.background.default,
}, },
}); });
// Dashboard is the main component, which renders the whole page, // Dashboard is the main component, which renders the whole page, makes connection with the server and listens for messages.
// makes connection with the server and listens for messages.
// When there is an incoming message, updates the page's content correspondingly. // When there is an incoming message, updates the page's content correspondingly.
class Dashboard extends Component { class Dashboard extends Component {
constructor(props) { constructor(props) {
@ -30,6 +45,7 @@ class Dashboard extends Component {
memory: [], memory: [],
traffic: [], traffic: [],
logs: [], logs: [],
shouldUpdate: {},
}; };
} }
@ -41,7 +57,7 @@ class Dashboard extends Component {
// reconnect establishes a websocket connection with the server, listens for incoming messages // reconnect establishes a websocket connection with the server, listens for incoming messages
// and tries to reconnect on connection loss. // and tries to reconnect on connection loss.
reconnect = () => { reconnect = () => {
const server = new WebSocket("ws://" + location.host + "/api"); const server = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/api");
server.onmessage = event => { server.onmessage = event => {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
@ -58,50 +74,56 @@ class Dashboard extends Component {
// update analyzes the incoming message, and updates the charts' content correspondingly. // update analyzes the incoming message, and updates the charts' content correspondingly.
update = msg => { update = msg => {
// (Re)initialize the state with the past data. metrics is set only in the first msg, console.log(msg);
// after the connection is established. this.setState(prevState => {
if (!isNullOrUndefined(msg.metrics)) { let newState = [];
let memory = []; newState.shouldUpdate = {};
let traffic = []; const insert = (key, values, limit) => {
if (!isNullOrUndefined(msg.metrics.memory)) { newState[key] = [...prevState[key], ...values];
memory = msg.metrics.memory.map(elem => ({memory: elem.value})); while (newState[key].length > limit) {
traffic = msg.metrics.processor.map(elem => ({traffic: defaultZero(elem.value)})) // TODO (kurkomisi): traffic != processor!!! newState[key].shift();
}
newState.shouldUpdate[key] = true;
};
// (Re)initialize the state with the past data.
if (!isNullOrUndefined(msg.history)) {
const memory = DATA_KEYS.memory;
const traffic = DATA_KEYS.traffic;
newState[memory] = [];
newState[traffic] = [];
if (!isNullOrUndefined(msg.history.memorySamples)) {
newState[memory] = msg.history.memorySamples.map(elem => isNullOrUndefined(elem.value) ? 0 : elem.value);
while (newState[memory].length > LIMIT.memory) {
newState[memory].shift();
}
newState.shouldUpdate[memory] = true;
}
if (!isNullOrUndefined(msg.history.trafficSamples)) {
newState[traffic] = msg.history.trafficSamples.map(elem => isNullOrUndefined(elem.value) ? 0 : elem.value);
while (newState[traffic].length > LIMIT.traffic) {
newState[traffic].shift();
}
newState.shouldUpdate[traffic] = true;
} }
this.setState({
memory: memory,
traffic: traffic,
logs: [],
});
} }
// Insert the new data samples. // Insert the new data samples.
isNullOrUndefined(msg.memory) || this.setState(prevState => { if (!isNullOrUndefined(msg.memory)) {
let memory = prevState.memory; insert(DATA_KEYS.memory, [isNullOrUndefined(msg.memory.value) ? 0 : msg.memory.value], LIMIT.memory);
let traffic = prevState.traffic; }
// Remove the first elements in case the samples' amount exceeds the limit. if (!isNullOrUndefined(msg.traffic)) {
if (memory.length === MEMORY_SAMPLE_LIMIT) { insert(DATA_KEYS.traffic, [isNullOrUndefined(msg.traffic.value) ? 0 : msg.traffic.value], LIMIT.traffic);
memory.shift(); }
traffic.shift(); if (!isNullOrUndefined(msg.log)) {
insert(DATA_KEYS.logs, [msg.log], LIMIT.log);
} }
return ({
memory: [...memory, {memory: msg.memory.value}],
traffic: [...traffic, {traffic: defaultZero(msg.processor.value)}],
});
});
// Insert the new log. return newState;
isNullOrUndefined(msg.log) || this.setState(prevState => {
let logs = prevState.logs;
if(logs.length > 20) {
logs.shift();
}
return {logs: [...logs, msg.log]};
}); });
}; };
// The change of the active label on the SideBar component will trigger a new render in the Main component. // The change of the active label on the SideBar component will trigger a new render in the Main component.
changeContent = active => { changeContent = active => {
this.state.active === active || this.setState({active: active}); this.setState(prevState => prevState.active !== active ? {active: active} : {});
}; };
openSideBar = () => { openSideBar = () => {
@ -133,6 +155,7 @@ class Dashboard extends Component {
memory={this.state.memory} memory={this.state.memory}
traffic={this.state.traffic} traffic={this.state.traffic}
logs={this.state.logs} logs={this.state.logs}
shouldUpdate={this.state.shouldUpdate}
/> />
</div> </div>
); );

View file

@ -1,3 +1,19 @@
// 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 React, {Component} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';

View file

@ -0,0 +1,89 @@
// 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 PropTypes from 'prop-types';
import Grid from 'material-ui/Grid';
import {LineChart, AreaChart, Area, YAxis, CartesianGrid, Line, ResponsiveContainer} from 'recharts';
import {withTheme} from 'material-ui/styles';
import {isNullOrUndefined, DATA_KEYS} from "./Common.jsx";
// ChartGrid renders a grid container for responsive charts.
// The children are Recharts components extended with the Material-UI's xs property.
class ChartGrid extends Component {
render() {
return (
<Grid container spacing={this.props.spacing}>
{
React.Children.map(this.props.children, child => (
<Grid item xs={child.props.xs}>
<ResponsiveContainer width="100%" height={child.props.height}>
{React.cloneElement(child, {data: child.props.values.map(value => ({value: value}))})}
</ResponsiveContainer>
</Grid>
))
}
</Grid>
);
}
}
ChartGrid.propTypes = {
spacing: PropTypes.number.isRequired,
};
// Home renders the home component.
class Home extends Component {
shouldComponentUpdate(nextProps) {
return !isNullOrUndefined(nextProps.shouldUpdate[DATA_KEYS.memory]) ||
!isNullOrUndefined(nextProps.shouldUpdate[DATA_KEYS.traffic]);
}
render() {
const {theme} = this.props;
const memoryColor = theme.palette.primary[300];
const trafficColor = theme.palette.secondary[300];
return (
<ChartGrid spacing={24}>
<AreaChart xs={6} height={300} values={this.props.memory}>
<YAxis />
<Area type="monotone" dataKey="value" stroke={memoryColor} fill={memoryColor} />
</AreaChart>
<LineChart xs={6} height={300} values={this.props.traffic}>
<Line type="monotone" dataKey="value" stroke={trafficColor} dot={false} />
</LineChart>
<LineChart xs={6} height={300} values={this.props.memory}>
<YAxis />
<CartesianGrid stroke="#eee" strokeDasharray="5 5" />
<Line type="monotone" dataKey="value" stroke={memoryColor} dot={false} />
</LineChart>
<AreaChart xs={6} height={300} values={this.props.traffic}>
<CartesianGrid stroke="#eee" strokeDasharray="5 5" vertical={false} />
<Area type="monotone" dataKey="value" stroke={trafficColor} fill={trafficColor} />
</AreaChart>
</ChartGrid>
);
}
}
Home.propTypes = {
theme: PropTypes.object.isRequired,
shouldUpdate: PropTypes.object.isRequired,
};
export default withTheme()(Home);

View file

@ -1,96 +1,44 @@
// 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 React, {Component} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import {withStyles} from 'material-ui/styles'; import {withStyles} from 'material-ui/styles';
import Grid from 'material-ui/Grid';
import {LineChart, AreaChart, Area, YAxis, CartesianGrid, Line, ResponsiveContainer} from 'recharts';
import {TAGS, DRAWER_WIDTH} from "./Common.jsx"; import {TAGS, DRAWER_WIDTH} from "./Common.jsx";
import Home from './Home.jsx';
// ChartGrid renders a grid container for responsive charts.
// The children are Recharts components extended with the Material-UI's xs property.
class ChartGrid extends Component {
render() {
return (
<Grid container spacing={this.props.spacing}>
{
React.Children.map(this.props.children, child => (
<Grid item xs={child.props.xs}>
<ResponsiveContainer width="100%" height={child.props.height}>
{child}
</ResponsiveContainer>
</Grid>
))
}
</Grid>
);
}
}
ChartGrid.propTypes = {
spacing: PropTypes.number.isRequired,
};
// ContentSwitch chooses and renders the proper page content. // ContentSwitch chooses and renders the proper page content.
class ContentSwitch extends Component { class ContentSwitch extends Component {
render() { render() {
switch(this.props.active) { switch(this.props.active) {
case TAGS.home.id: case TAGS.home.id:
return ( return <Home memory={this.props.memory} traffic={this.props.traffic} shouldUpdate={this.props.shouldUpdate} />;
<ChartGrid spacing={24}> case TAGS.chain.id:
<AreaChart xs={6} height={300} data={this.props.memory}> return null;
<YAxis /> case TAGS.transactions.id:
<Area type="monotone" dataKey="memory" stroke="#8884d8" fill="#8884d8" /> return null;
</AreaChart> case TAGS.network.id:
<LineChart xs={6} height={300} data={this.props.traffic}> // Only for testing.
<Line type="monotone" dataKey="traffic" dot={false} /> return null;
</LineChart> case TAGS.system.id:
<LineChart xs={6} height={300} data={this.props.memory}> return null;
<YAxis />
<CartesianGrid stroke="#eee" strokeDasharray="5 5" />
<Line type="monotone" dataKey="memory" stroke="#8884d8" dot={false} />
</LineChart>
<AreaChart xs={6} height={300} data={this.props.traffic}>
<CartesianGrid stroke="#eee" strokeDasharray="5 5" vertical={false} />
<Area type="monotone" dataKey="traffic" />
</AreaChart>
</ChartGrid>
);
case TAGS.logs.id: case TAGS.logs.id:
return <div>{this.props.logs.map((log, index) => <div key={index}>{log}</div>)}</div>; return <div>{this.props.logs.map((log, index) => <div key={index}>{log}</div>)}</div>;
case TAGS.networking.id:
// Only for testing.
return (
<Grid container spacing={24}>
<Grid item xs={6}>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={this.props.traffic}>
<Line type="monotone" dataKey="traffic" dot={false} />
</LineChart>
</ResponsiveContainer>
</Grid>
<Grid item xs={6}>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={this.props.memory}>
<YAxis />
<Area type="monotone" dataKey="memory" stroke="#8884d8" fill="#8884d8" />
</AreaChart>
</ResponsiveContainer>
</Grid>
<Grid item xs={6}>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={this.props.traffic}>
<CartesianGrid stroke="#eee" strokeDasharray="5 5" vertical={false} />
<Area type="monotone" dataKey="traffic" />
</AreaChart>
</ResponsiveContainer>
</Grid>
</Grid>
);
case TAGS.txpool.id:
case TAGS.blockchain.id:
case TAGS.system.id:
} }
return null; return null;
} }
@ -98,9 +46,10 @@ class ContentSwitch extends Component {
ContentSwitch.propTypes = { ContentSwitch.propTypes = {
active: PropTypes.string.isRequired, active: PropTypes.string.isRequired,
shouldUpdate: PropTypes.object.isRequired,
}; };
// Styles for the Main component. // styles contains the styles for the Main component.
const styles = theme => ({ const styles = theme => ({
content: { content: {
width: '100%', width: '100%',
@ -143,6 +92,7 @@ class Main extends Component {
memory={this.props.memory} memory={this.props.memory}
traffic={this.props.traffic} traffic={this.props.traffic}
logs={this.props.logs} logs={this.props.logs}
shouldUpdate={this.props.shouldUpdate}
/> />
</main> </main>
); );
@ -153,6 +103,7 @@ Main.propTypes = {
classes: PropTypes.object.isRequired, classes: PropTypes.object.isRequired,
opened: PropTypes.bool.isRequired, opened: PropTypes.bool.isRequired,
active: PropTypes.string.isRequired, active: PropTypes.string.isRequired,
shouldUpdate: PropTypes.object.isRequired,
}; };
export default withStyles(styles)(Main); export default withStyles(styles)(Main);

View file

@ -1,3 +1,19 @@
// 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 React, {Component} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {withStyles} from 'material-ui/styles'; import {withStyles} from 'material-ui/styles';
@ -39,8 +55,9 @@ class SideBar extends Component {
this.clickOn = {}; this.clickOn = {};
for(let key in TAGS) { for(let key in TAGS) {
const id = TAGS[key].id; const id = TAGS[key].id;
this.clickOn[id] = e => { this.clickOn[id] = event => {
e.preventDefault(); event.preventDefault();
console.log(event.target.key);
this.props.changeContent(id); this.props.changeContent(id);
}; };
} }
@ -69,7 +86,7 @@ class SideBar extends Component {
<ListItem button key={tag.id} onClick={this.clickOn[tag.id]}> <ListItem button key={tag.id} onClick={this.clickOn[tag.id]}>
<ListItemText primary={tag.title} /> <ListItemText primary={tag.title} />
</ListItem> </ListItem>
) );
}) })
} }
</List> </List>

View file

@ -1,3 +1,19 @@
// 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 React from 'react';
import {hydrate} from 'react-dom'; import {hydrate} from 'react-dom';
import {createMuiTheme, MuiThemeProvider} from 'material-ui/styles'; import {createMuiTheme, MuiThemeProvider} from 'material-ui/styles';
@ -17,4 +33,4 @@ hydrate(
<Dashboard /> <Dashboard />
</MuiThemeProvider>, </MuiThemeProvider>,
document.getElementById('dashboard') document.getElementById('dashboard')
); // server-side rendering );

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,19 @@
// 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/>.
const path = require('path'); const path = require('path');
module.exports = { module.exports = {

View file

@ -36,7 +36,7 @@ type Config struct {
// for ephemeral nodes). // for ephemeral nodes).
Port int `toml:",omitempty"` Port int `toml:",omitempty"`
// Refresh is the refresh rate of the data updates, the data will be collected this often. // Refresh is the refresh rate of the data updates, the chartEntry will be collected this often.
Refresh time.Duration `toml:",omitempty"` Refresh time.Duration `toml:",omitempty"`
// Assets offers a possibility to manually set the dashboard website's location on the server side. // Assets offers a possibility to manually set the dashboard website's location on the server side.

View file

@ -19,7 +19,6 @@ package dashboard
//go:generate go-bindata -nometadata -o assets.go -prefix assets -pkg dashboard assets/public/... //go:generate go-bindata -nometadata -o assets.go -prefix assets -pkg dashboard assets/public/...
import ( import (
"bytes"
"fmt" "fmt"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/p2p" "github.com/ethereum/go-ethereum/p2p"
@ -29,62 +28,65 @@ import (
"io/ioutil" "io/ioutil"
"net" "net"
"net/http" "net/http"
"path/filepath"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
) )
const ( const (
processorSampleLimit = 200 memorySampleLimit = 200 // Maximum number of memory data samples
memorySampleLimit = 200 trafficSampleLimit = 200 // Maximum number of traffic data samples
trafficSampleLimit = 200
) )
var ( var nextId uint32 // Next connection id
nextId uint32 // Next connection id
)
// dashboard contains the dashboard internals.
type dashboard struct { type dashboard struct {
config *Config config *Config
listener net.Listener listener net.Listener
conns map[uint32]*client // Currently live websocket connections conns map[uint32]*client // Currently live websocket connections
Metrics *metricSamples `json:"metrics,omitempty"` charts charts // The collected data samples to plot
Stats *status `json:"stats,omitempty"`
lock sync.RWMutex // Lock protecting the dashboard's internals lock sync.RWMutex // Lock protecting the dashboard's internals
closing chan chan error // Channel used for graceful exit quit chan chan error // Channel used for graceful exit
wg sync.WaitGroup wg sync.WaitGroup
} }
// message embraces the data samples of a client message.
type message struct {
History *charts `json:"history,omitempty"` // Past data samples
Memory *chartEntry `json:"memory,omitempty"` // One memory sample
Traffic *chartEntry `json:"traffic,omitempty"` // One traffic sample
Log string `json:"log,omitempty"` // One log
}
// client represents active websocket connection with a remote browser.
type client struct { type client struct {
conn *websocket.Conn // Particular live websocket connection conn *websocket.Conn // Particular live websocket connection
msg chan *map[string]interface{} // Message queue for the update messages msg chan message // Message queue for the update messages
logger log.Logger // Logger for the particular live websocket connection logger log.Logger // Logger for the particular live websocket connection
} }
type metricSamples struct { // charts contains the collected data samples.
Processor []*data `json:"processor,omitempty"` type charts struct {
Memory []*data `json:"memory,omitempty"` Memory []*chartEntry `json:"memorySamples,omitempty"`
Traffic []*chartEntry `json:"trafficSamples,omitempty"`
} }
type data struct { // chartEntry represents one data sample
type chartEntry struct {
Time time.Time `json:"time,omitempty"` Time time.Time `json:"time,omitempty"`
Value float64 `json:"value,omitempty"` Value float64 `json:"value,omitempty"`
} }
type status struct {
Peers int `json:"peers,omitempty"`
Block int `json:"block,omitempty"`
}
// New creates a new dashboard instance with the given configuration. // New creates a new dashboard instance with the given configuration.
func New(config *Config) (*dashboard, error) { func New(config *Config) (*dashboard, error) {
return &dashboard{ return &dashboard{
conns: make(map[uint32]*client), conns: make(map[uint32]*client),
config: config, config: config,
Metrics: &metricSamples{}, quit: make(chan chan error),
closing: make(chan chan error),
}, nil }, nil
} }
@ -116,18 +118,20 @@ func (db *dashboard) Start(server *p2p.Server) error {
// Stop implements node.Service, stopping the data collection thread and the connection listener of the dashboard. // Stop implements node.Service, stopping the data collection thread and the connection listener of the dashboard.
func (db *dashboard) Stop() error { func (db *dashboard) Stop() error {
var err error // Close the connection listener.
// Close the connection listener var errs []error
if err = db.listener.Close(); err != nil { if err := db.listener.Close(); err != nil {
log.Warn("Failed to close listener", "err", err) errs = append(errs, err)
} }
// Close the collectors.
errc := make(chan error) errc := make(chan error, 1)
db.closing <- errc // collectData for i := 0; i < 2; i++ {
<-errc db.quit <- errc
db.closing <- errc // collectLogs if err := <-errc; err != nil {
<-errc errs = append(errs, err)
}
}
// Close the connections.
db.lock.Lock() db.lock.Lock()
for _, c := range db.conns { for _, c := range db.conns {
if err := c.conn.Close(); err != nil { if err := c.conn.Close(); err != nil {
@ -136,18 +140,16 @@ func (db *dashboard) Stop() error {
} }
db.lock.Unlock() db.lock.Unlock()
// Wait until every goroutine terminates.
db.wg.Wait() db.wg.Wait()
log.Info("Dashboard stopped") log.Info("Dashboard stopped")
return err var err error
if len(errs) > 0 {
err = fmt.Errorf("%v", errs)
} }
func join(strings ...string) *bytes.Buffer { return err
var buffer bytes.Buffer
for _, s := range strings {
buffer.WriteString(s)
}
return &buffer
} }
// webHandler handles all non-api requests, simply flattening and returning the dashboard website. // webHandler handles all non-api requests, simply flattening and returning the dashboard website.
@ -158,26 +160,24 @@ func (db *dashboard) webHandler(w http.ResponseWriter, r *http.Request) {
if path == "/" { if path == "/" {
path = "/dashboard.html" path = "/dashboard.html"
} }
// If the path of the assets is manually set // If the path of the assets is manually set
if db.config.Assets != "" { if db.config.Assets != "" {
file, err := ioutil.ReadFile(join(db.config.Assets, path).String()) blob, err := ioutil.ReadFile(filepath.Join(db.config.Assets, path))
if err != nil { if err != nil {
log.Warn("Failed to read file", "err", err) log.Warn("Failed to read file", "path", path, "err", err)
http.Error(w, "not found", http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return return
} }
w.Write(file) w.Write(blob)
return return
} }
blob, err := Asset(filepath.Join("public", path))
webapp, err := Asset(join("public", path).String())
if err != nil { if err != nil {
log.Warn("Failed to load the asset", "path", path, "err", err) log.Warn("Failed to load the asset", "path", path, "err", err)
http.Error(w, "not found", http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return return
} }
w.Write(webapp) w.Write(blob)
} }
// apiHandler handles requests for the dashboard. // apiHandler handles requests for the dashboard.
@ -185,11 +185,10 @@ func (db *dashboard) apiHandler(conn *websocket.Conn) {
id := atomic.AddUint32(&nextId, 1) id := atomic.AddUint32(&nextId, 1)
client := &client{ client := &client{
conn: conn, conn: conn,
msg: make(chan *map[string]interface{}, 128), msg: make(chan message, 128),
logger: log.New("id", id), logger: log.New("id", id),
} }
done := make(chan struct{}) // Buffered channel as sender may exit early
loss := make(chan bool, 1) // Buffered channel as sender may exit early
// Start listening for messages to send. // Start listening for messages to send.
db.wg.Add(1) db.wg.Add(1)
@ -198,22 +197,21 @@ func (db *dashboard) apiHandler(conn *websocket.Conn) {
for { for {
select { select {
case <-loss: case <-done:
return return
case msg := <-client.msg: case msg := <-client.msg:
if err := websocket.JSON.Send(client.conn, msg); err != nil { if err := websocket.JSON.Send(client.conn, msg); err != nil {
client.logger.Warn("Failed to send the message", "msg", msg, "err", err) client.logger.Warn("Failed to send the message", "msg", msg, "err", err)
client.conn.Close()
return return
} }
} }
} }
}() }()
// Send the past data. // Send the past data.
client.msg <- &map[string]interface{}{ client.msg <- message{
"metrics": db.Metrics, History: &db.charts,
} }
// Start tracking the connection and drop at connection loss. // Start tracking the connection and drop at connection loss.
db.lock.Lock() db.lock.Lock()
db.conns[id] = client db.conns[id] = client
@ -223,11 +221,10 @@ func (db *dashboard) apiHandler(conn *websocket.Conn) {
delete(db.conns, id) delete(db.conns, id)
db.lock.Unlock() db.lock.Unlock()
}() }()
for { for {
fail := []byte{} fail := []byte{}
if _, err := conn.Read(fail); err != nil { if _, err := conn.Read(fail); err != nil {
loss <- true close(done)
return return
} }
// Ignore all messages // Ignore all messages
@ -240,24 +237,37 @@ func (db *dashboard) collectData() {
for { for {
select { select {
case errc := <-db.closing: case errc := <-db.quit:
errc <- nil errc <- nil
return return
case <-time.After(db.config.Refresh): case <-time.After(db.config.Refresh):
now := time.Now() inboundTraffic := metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Rate1()
traffic := metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Rate1()
memoryInUse := metrics.DefaultRegistry.Get("system/memory/inuse").(metrics.Meter).Rate1() memoryInUse := metrics.DefaultRegistry.Get("system/memory/inuse").(metrics.Meter).Rate1()
traff := &data{ now := time.Now()
Time: now, memory := &chartEntry{
Value: traffic,
}
memory := &data{
Time: now, Time: now,
Value: memoryInUse, Value: memoryInUse,
} }
traffic := &chartEntry{
Time: now,
Value: inboundTraffic,
}
// Remove the first elements in case the samples' amount exceeds the limit.
first := 0
if len(db.charts.Memory) == memorySampleLimit {
first = 1
}
db.charts.Memory = append(db.charts.Memory[first:], memory)
first = 0
if len(db.charts.Traffic) == trafficSampleLimit {
first = 1
}
db.charts.Traffic = append(db.charts.Traffic[first:], traffic)
// TODO (kurkomisi): do not mix traffic with processor! db.sendToAll(&message{
db.update(traff, memory) Memory: memory,
Traffic: traffic,
})
} }
} }
} }
@ -269,45 +279,25 @@ func (db *dashboard) collectLogs() {
// TODO (kurkomisi): log collection comes here. // TODO (kurkomisi): log collection comes here.
for { for {
select { select {
case errc := <-db.closing: case errc := <-db.quit:
errc <- nil errc <- nil
return return
case <-time.After(db.config.Refresh): case <-time.After(db.config.Refresh / 2):
db.sendToAll(&map[string]interface{}{ db.sendToAll(&message{
"log": "This is a fake log.", Log: "This is a fake log.",
}) })
} }
} }
} }
// update updates the dashboards through the live websocket connections. // sendToAll sends the given message to the active dashboards.
func (db *dashboard) update(processor *data, memory *data) { func (db *dashboard) sendToAll(msg *message) {
// Remove the first elements in case the samples' amount exceeds the limit.
first := 0
if len(db.Metrics.Processor) == processorSampleLimit {
first = 1
}
db.Metrics.Processor = append(db.Metrics.Processor[first:], processor)
first = 0
if len(db.Metrics.Memory) == memorySampleLimit {
first = 1
}
db.Metrics.Memory = append(db.Metrics.Memory[first:], memory)
db.sendToAll(&map[string]interface{}{
"processor": processor,
"memory": memory,
})
}
// Sends the given message to the active dashboards.
func (db *dashboard) sendToAll(msg *map[string]interface{}) {
db.lock.Lock() db.lock.Lock()
for _, c := range db.conns { for _, c := range db.conns {
select { select {
case c.msg <- msg: case c.msg <- *msg:
default: default:
c.logger.Warn("Client message queue is full") c.conn.Close()
} }
} }
db.lock.Unlock() db.lock.Unlock()

View file

@ -30,6 +30,7 @@ import (
// MetricsEnabledFlag is the CLI flag name to use to enable metrics collections. // MetricsEnabledFlag is the CLI flag name to use to enable metrics collections.
const MetricsEnabledFlag = "metrics" const MetricsEnabledFlag = "metrics"
const DashboardEnabledFlag = "dashboard"
// Enabled is the flag specifying if metrics are enable or not. // Enabled is the flag specifying if metrics are enable or not.
var Enabled = false var Enabled = false
@ -39,7 +40,7 @@ var Enabled = false
// and peek into the command line args for the metrics flag. // and peek into the command line args for the metrics flag.
func init() { func init() {
for _, arg := range os.Args { for _, arg := range os.Args {
if strings.TrimLeft(arg, "-") == MetricsEnabledFlag { if flag := strings.TrimLeft(arg, "-"); flag == MetricsEnabledFlag || flag == DashboardEnabledFlag {
log.Info("Enabling metrics collection") log.Info("Enabling metrics collection")
Enabled = true Enabled = true
} }