mirror of https://github.com/ethereum/go-ethereum
cmd, dashboard: dashboard using React, Material-UI, Recharts (#15393)
* cmd, dashboard: dashboard using React, Material-UI, Recharts * cmd, dashboard, metrics: initial proof of concept dashboard * dashboard: delete blobs * dashboard: gofmt -s -w . * dashboard: minor text and code polishespull/15483/merge
parent
984c25ac40
commit
ba62215d9e
@ -0,0 +1,46 @@ |
||||
## 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 && npm install) |
||||
``` |
||||
|
||||
Normally the dashboard assets are bundled into Geth via `go-bindata` to avoid external dependencies. Rebuilding Geth after each UI modification however is not feasible from a developer perspective. Instead, we can run `webpack` in watch mode to automatically rebundle the UI, and ask `geth` to use external assets to not rely on compiled resources: |
||||
|
||||
``` |
||||
$ (cd dashboard/assets && ./node_modules/.bin/webpack --watch) |
||||
$ geth --dashboard --dashboard.assets=dashboard/assets/public --vmodule=dashboard=5 |
||||
``` |
||||
|
||||
To bundle up the final UI into Geth, run `webpack` and `go generate`: |
||||
|
||||
``` |
||||
$ (cd dashboard/assets && ./node_modules/.bin/webpack) |
||||
$ go generate ./dashboard |
||||
``` |
||||
|
||||
### 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 _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/ |
File diff suppressed because one or more lines are too long
@ -0,0 +1,52 @@ |
||||
// 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 |
||||
{ |
||||
"plugins": [ |
||||
"react" |
||||
], |
||||
"parser": "babel-eslint", |
||||
"parserOptions": { |
||||
"ecmaFeatures": { |
||||
"jsx": true, |
||||
"modules": true |
||||
} |
||||
}, |
||||
"rules": { |
||||
"react/prefer-es6-class": 2, |
||||
"react/prefer-stateless-function": 2, |
||||
"react/jsx-pascal-case": 2, |
||||
"react/jsx-closing-bracket-location": [1, {"selfClosing": "tag-aligned", "nonEmpty": "tag-aligned"}], |
||||
"react/jsx-closing-tag-location": 1, |
||||
"jsx-quotes": ["error", "prefer-double"], |
||||
"no-multi-spaces": "error", |
||||
"react/jsx-tag-spacing": 2, |
||||
"react/jsx-curly-spacing": [2, {"when": "never", "children": true}], |
||||
"react/jsx-boolean-value": 2, |
||||
"react/no-string-refs": 2, |
||||
"react/jsx-wrap-multilines": 2, |
||||
"react/self-closing-comp": 2, |
||||
"react/jsx-no-bind": 2, |
||||
"react/require-render-return": 2, |
||||
"react/no-is-mounted": 2, |
||||
"key-spacing": ["error", {"align": { |
||||
"beforeColon": false, |
||||
"afterColon": true, |
||||
"on": "value" |
||||
}}] |
||||
} |
||||
} |
@ -0,0 +1,52 @@ |
||||
// 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. |
||||
export const isNullOrUndefined = variable => variable === null || typeof variable === 'undefined'; |
||||
|
||||
export const LIMIT = { |
||||
memory: 200, // Maximum number of memory data samples. |
||||
traffic: 200, // Maximum number of traffic data samples. |
||||
log: 200, // Maximum number of logs. |
||||
}; |
||||
// The sidebar menu and the main content are rendered based on these elements. |
||||
export const TAGS = (() => { |
||||
const T = { |
||||
home: { title: "Home", }, |
||||
chain: { title: "Chain", }, |
||||
transactions: { title: "Transactions", }, |
||||
network: { title: "Network", }, |
||||
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. |
||||
// This way the mistyping is prevented. |
||||
for(let key in T) { |
||||
T[key]['id'] = key; |
||||
} |
||||
return T; |
||||
})(); |
||||
|
||||
export const DATA_KEYS = (() => { |
||||
const DK = {}; |
||||
["memory", "traffic", "logs"].map(key => { |
||||
DK[key] = key; |
||||
}); |
||||
return DK; |
||||
})(); |
||||
|
||||
// Temporary - taken from Material-UI |
||||
export const DRAWER_WIDTH = 240; |
@ -0,0 +1,169 @@ |
||||
// 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 {withStyles} from 'material-ui/styles'; |
||||
|
||||
import SideBar from './SideBar.jsx'; |
||||
import Header from './Header.jsx'; |
||||
import Main from "./Main.jsx"; |
||||
import {isNullOrUndefined, LIMIT, TAGS, DATA_KEYS,} from "./Common.jsx"; |
||||
|
||||
// Styles for the Dashboard component. |
||||
const styles = theme => ({ |
||||
appFrame: { |
||||
position: 'relative', |
||||
display: 'flex', |
||||
width: '100%', |
||||
height: '100%', |
||||
background: theme.palette.background.default, |
||||
}, |
||||
}); |
||||
|
||||
// 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 { |
||||
constructor(props) { |
||||
super(props); |
||||
this.state = { |
||||
active: TAGS.home.id, // active menu |
||||
sideBar: true, // true if the sidebar is opened |
||||
memory: [], |
||||
traffic: [], |
||||
logs: [], |
||||
shouldUpdate: {}, |
||||
}; |
||||
} |
||||
|
||||
// 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 server = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/api"); |
||||
|
||||
server.onmessage = event => { |
||||
const msg = JSON.parse(event.data); |
||||
if (isNullOrUndefined(msg)) { |
||||
return; |
||||
} |
||||
this.update(msg); |
||||
}; |
||||
|
||||
server.onclose = () => { |
||||
setTimeout(this.reconnect, 3000); |
||||
}; |
||||
}; |
||||
|
||||
// update analyzes the incoming message, and updates the charts' content correspondingly. |
||||
update = msg => { |
||||
console.log(msg); |
||||
this.setState(prevState => { |
||||
let newState = []; |
||||
newState.shouldUpdate = {}; |
||||
const insert = (key, values, limit) => { |
||||
newState[key] = [...prevState[key], ...values]; |
||||
while (newState[key].length > limit) { |
||||
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; |
||||
} |
||||
} |
||||
// Insert the new data samples. |
||||
if (!isNullOrUndefined(msg.memory)) { |
||||
insert(DATA_KEYS.memory, [isNullOrUndefined(msg.memory.value) ? 0 : msg.memory.value], LIMIT.memory); |
||||
} |
||||
if (!isNullOrUndefined(msg.traffic)) { |
||||
insert(DATA_KEYS.traffic, [isNullOrUndefined(msg.traffic.value) ? 0 : msg.traffic.value], LIMIT.traffic); |
||||
} |
||||
if (!isNullOrUndefined(msg.log)) { |
||||
insert(DATA_KEYS.logs, [msg.log], LIMIT.log); |
||||
} |
||||
|
||||
return newState; |
||||
}); |
||||
}; |
||||
|
||||
// The change of the active label on the SideBar component will trigger a new render in the Main component. |
||||
changeContent = active => { |
||||
this.setState(prevState => prevState.active !== active ? {active: active} : {}); |
||||
}; |
||||
|
||||
openSideBar = () => { |
||||
this.setState({sideBar: true}); |
||||
}; |
||||
|
||||
closeSideBar = () => { |
||||
this.setState({sideBar: false}); |
||||
}; |
||||
|
||||
render() { |
||||
// The classes property is injected by withStyles(). |
||||
const {classes} = this.props; |
||||
|
||||
return ( |
||||
<div className={classes.appFrame}> |
||||
<Header |
||||
opened={this.state.sideBar} |
||||
open={this.openSideBar} |
||||
/> |
||||
<SideBar |
||||
opened={this.state.sideBar} |
||||
close={this.closeSideBar} |
||||
changeContent={this.changeContent} |
||||
/> |
||||
<Main |
||||
opened={this.state.sideBar} |
||||
active={this.state.active} |
||||
memory={this.state.memory} |
||||
traffic={this.state.traffic} |
||||
logs={this.state.logs} |
||||
shouldUpdate={this.state.shouldUpdate} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
Dashboard.propTypes = { |
||||
classes: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
export default withStyles(styles)(Dashboard); |
@ -0,0 +1,87 @@ |
||||
// 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 classNames from 'classnames'; |
||||
import {withStyles} from 'material-ui/styles'; |
||||
import AppBar from 'material-ui/AppBar'; |
||||
import Toolbar from 'material-ui/Toolbar'; |
||||
import Typography from 'material-ui/Typography'; |
||||
import IconButton from 'material-ui/IconButton'; |
||||
import MenuIcon from 'material-ui-icons/Menu'; |
||||
|
||||
import {DRAWER_WIDTH} from './Common.jsx'; |
||||
|
||||
// Styles for the Header component. |
||||
const styles = theme => ({ |
||||
appBar: { |
||||
position: 'absolute', |
||||
transition: theme.transitions.create(['margin', 'width'], { |
||||
easing: theme.transitions.easing.sharp, |
||||
duration: theme.transitions.duration.leavingScreen, |
||||
}), |
||||
}, |
||||
appBarShift: { |
||||
marginLeft: DRAWER_WIDTH, |
||||
width: `calc(100% - ${DRAWER_WIDTH}px)`, |
||||
transition: theme.transitions.create(['margin', 'width'], { |
||||
easing: theme.transitions.easing.easeOut, |
||||
duration: theme.transitions.duration.enteringScreen, |
||||
}), |
||||
}, |
||||
menuButton: { |
||||
marginLeft: 12, |
||||
marginRight: 20, |
||||
}, |
||||
hide: { |
||||
display: 'none', |
||||
}, |
||||
}); |
||||
|
||||
// Header renders a header, which contains a sidebar opener icon when that is closed. |
||||
class Header extends Component { |
||||
render() { |
||||
// The classes property is injected by withStyles(). |
||||
const {classes} = this.props; |
||||
|
||||
return ( |
||||
<AppBar className={classNames(classes.appBar, this.props.opened && classes.appBarShift)}> |
||||
<Toolbar disableGutters={!this.props.opened}> |
||||
<IconButton |
||||
color="contrast" |
||||
aria-label="open drawer" |
||||
onClick={this.props.open} |
||||
className={classNames(classes.menuButton, this.props.opened && classes.hide)} |
||||
> |
||||
<MenuIcon /> |
||||
</IconButton> |
||||
<Typography type="title" color="inherit" noWrap> |
||||
Go Ethereum Dashboard |
||||
</Typography> |
||||
</Toolbar> |
||||
</AppBar> |
||||
); |
||||
} |
||||
} |
||||
|
||||
Header.propTypes = { |
||||
classes: PropTypes.object.isRequired, |
||||
opened: PropTypes.bool.isRequired, |
||||
open: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
export default withStyles(styles)(Header); |
@ -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); |
@ -0,0 +1,109 @@ |
||||
// 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 classNames from 'classnames'; |
||||
import {withStyles} from 'material-ui/styles'; |
||||
|
||||
import {TAGS, DRAWER_WIDTH} from "./Common.jsx"; |
||||
import Home from './Home.jsx'; |
||||
|
||||
// ContentSwitch chooses and renders the proper page content. |
||||
class ContentSwitch extends Component { |
||||
render() { |
||||
switch(this.props.active) { |
||||
case TAGS.home.id: |
||||
return <Home memory={this.props.memory} traffic={this.props.traffic} shouldUpdate={this.props.shouldUpdate} />; |
||||
case TAGS.chain.id: |
||||
return null; |
||||
case TAGS.transactions.id: |
||||
return null; |
||||
case TAGS.network.id: |
||||
// Only for testing. |
||||
return null; |
||||
case TAGS.system.id: |
||||
return null; |
||||
case TAGS.logs.id: |
||||
return <div>{this.props.logs.map((log, index) => <div key={index}>{log}</div>)}</div>; |
||||
} |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
ContentSwitch.propTypes = { |
||||
active: PropTypes.string.isRequired, |
||||
shouldUpdate: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
// styles contains the styles for the Main component. |
||||
const styles = theme => ({ |
||||
content: { |
||||
width: '100%', |
||||
marginLeft: -DRAWER_WIDTH, |
||||
flexGrow: 1, |
||||
backgroundColor: theme.palette.background.default, |
||||
padding: theme.spacing.unit * 3, |
||||
transition: theme.transitions.create('margin', { |
||||
easing: theme.transitions.easing.sharp, |
||||
duration: theme.transitions.duration.leavingScreen, |
||||
}), |
||||
marginTop: 56, |
||||
overflow: 'auto', |
||||
[theme.breakpoints.up('sm')]: { |
||||
content: { |
||||
height: 'calc(100% - 64px)', |
||||
marginTop: 64, |
||||
}, |
||||
}, |
||||
}, |
||||
contentShift: { |
||||
marginLeft: 0, |
||||
transition: theme.transitions.create('margin', { |
||||
easing: theme.transitions.easing.easeOut, |
||||
duration: theme.transitions.duration.enteringScreen, |
||||
}), |
||||
}, |
||||
}); |
||||
|
||||
// Main renders a component for the page content. |
||||
class Main extends Component { |
||||
render() { |
||||
// The classes property is injected by withStyles(). |
||||
const {classes} = this.props; |
||||
|
||||
return ( |
||||
<main className={classNames(classes.content, this.props.opened && classes.contentShift)}> |
||||
<ContentSwitch |
||||
active={this.props.active} |
||||
memory={this.props.memory} |
||||
traffic={this.props.traffic} |
||||
logs={this.props.logs} |
||||
shouldUpdate={this.props.shouldUpdate} |
||||
/> |
||||
</main> |
||||
); |
||||
} |
||||
} |
||||
|
||||
Main.propTypes = { |
||||
classes: PropTypes.object.isRequired, |
||||
opened: PropTypes.bool.isRequired, |
||||
active: PropTypes.string.isRequired, |
||||
shouldUpdate: PropTypes.object.isRequired, |
||||
}; |
||||
|
||||
export default withStyles(styles)(Main); |
@ -0,0 +1,106 @@ |
||||
// 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 {withStyles} from 'material-ui/styles'; |
||||
import Drawer from 'material-ui/Drawer'; |
||||
import {IconButton} from "material-ui"; |
||||
import List, {ListItem, ListItemText} from 'material-ui/List'; |
||||
import ChevronLeftIcon from 'material-ui-icons/ChevronLeft'; |
||||
|
||||
import {TAGS, DRAWER_WIDTH} from './Common.jsx'; |
||||
|
||||
// Styles for the SideBar component. |
||||
const styles = theme => ({ |
||||
drawerPaper: { |
||||
position: 'relative', |
||||
height: '100%', |
||||
width: DRAWER_WIDTH, |
||||
}, |
||||
drawerHeader: { |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'flex-end', |
||||
padding: '0 8px', |
||||
...theme.mixins.toolbar, |
||||
transitionDuration: { |
||||
enter: theme.transitions.duration.enteringScreen, |
||||
exit: theme.transitions.duration.leavingScreen, |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
// SideBar renders a sidebar component. |
||||
class SideBar extends Component { |
||||
constructor(props) { |
||||
super(props); |
||||
|
||||
// clickOn contains onClick event functions for the menu items. |
||||
// Instantiate only once, and reuse the existing functions to prevent the creation of |
||||
// new function instances every time the render method is triggered. |
||||
this.clickOn = {}; |
||||
for(let key in TAGS) { |
||||
const id = TAGS[key].id; |
||||
this.clickOn[id] = event => { |
||||
event.preventDefault(); |
||||
console.log(event.target.key); |
||||
this.props.changeContent(id); |
||||
}; |
||||
} |
||||
} |
||||
|
||||
render() { |
||||
// The classes property is injected by withStyles(). |
||||
const {classes} = this.props; |
||||
|
||||
return ( |
||||
<Drawer |
||||
type="persistent" |
||||
classes={{paper: classes.drawerPaper,}} |
||||
open={this.props.opened} |
||||
> |
||||
<div> |
||||
<div className={classes.drawerHeader}> |
||||
<IconButton onClick={this.props.close}> |
||||
<ChevronLeftIcon /> |
||||
</IconButton> |
||||
</div> |
||||
<List> |
||||
{ |
||||
Object.values(TAGS).map(tag => { |
||||
return ( |
||||
<ListItem button key={tag.id} onClick={this.clickOn[tag.id]}> |
||||
<ListItemText primary={tag.title} /> |
||||
</ListItem> |
||||
); |
||||
}) |
||||
} |
||||
</List> |
||||
</div> |
||||
</Drawer> |
||||
); |
||||
} |
||||
} |
||||
|
||||
SideBar.propTypes = { |
||||
classes: PropTypes.object.isRequired, |
||||
opened: PropTypes.bool.isRequired, |
||||
close: PropTypes.func.isRequired, |
||||
changeContent: PropTypes.func.isRequired, |
||||
}; |
||||
|
||||
export default withStyles(styles)(SideBar); |
@ -0,0 +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/>. |
||||
|
||||
import React from 'react'; |
||||
import {hydrate} from 'react-dom'; |
||||
import {createMuiTheme, MuiThemeProvider} from 'material-ui/styles'; |
||||
|
||||
import Dashboard from './components/Dashboard.jsx'; |
||||
|
||||
// Theme for the dashboard. |
||||
const theme = createMuiTheme({ |
||||
palette: { |
||||
type: 'dark', |
||||
}, |
||||
}); |
||||
|
||||
// Renders the whole dashboard. |
||||
hydrate( |
||||
<MuiThemeProvider theme={theme}> |
||||
<Dashboard /> |
||||
</MuiThemeProvider>, |
||||
document.getElementById('dashboard') |
||||
); |
@ -0,0 +1,22 @@ |
||||
{ |
||||
"dependencies": { |
||||
"babel-core": "^6.26.0", |
||||
"babel-eslint": "^8.0.1", |
||||
"babel-loader": "^7.1.2", |
||||
"babel-preset-env": "^1.6.1", |
||||
"babel-preset-react": "^6.24.1", |
||||
"babel-preset-stage-0": "^6.24.1", |
||||
"classnames": "^2.2.5", |
||||
"eslint": "^4.5.0", |
||||
"eslint-plugin-react": "^7.4.0", |
||||
"material-ui": "^1.0.0-beta.18", |
||||
"material-ui-icons": "^1.0.0-beta.17", |
||||
"path": "^0.12.7", |
||||
"prop-types": "^15.6.0", |
||||
"recharts": "^1.0.0-beta.0", |
||||
"react": "^16.0.0", |
||||
"react-dom": "^16.0.0", |
||||
"url": "^0.11.0", |
||||
"webpack": "^3.5.5" |
||||
} |
||||
} |
@ -0,0 +1,17 @@ |
||||
<!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"/> |
||||
|
||||
<!-- TODO (kurkomisi): Return to the external libraries to speed up the bundling during development --> |
||||
</head> |
||||
<body style="height: 100%; margin: 0"> |
||||
<div id="dashboard" style="height: 100%"></div> |
||||
<script src="bundle.js"></script> |
||||
</body> |
||||
</html> |
@ -0,0 +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/>.
|
||||
|
||||
const path = require('path'); |
||||
|
||||
module.exports = { |
||||
entry: './index.jsx', |
||||
output: { |
||||
path: path.resolve(__dirname, 'public'), |
||||
filename: 'bundle.js', |
||||
}, |
||||
module: { |
||||
loaders: [ |
||||
{ |
||||
test: /\.jsx$/, // regexp for JSX files
|
||||
loader: 'babel-loader', // The babel configuration is in the package.json.
|
||||
query: { |
||||
presets: ['env', 'react', 'stage-0'] |
||||
} |
||||
}, |
||||
], |
||||
}, |
||||
}; |
@ -0,0 +1,45 @@ |
||||
// 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: 3 * 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"` |
||||
|
||||
// Assets offers a possibility to manually set the dashboard website's location on the server side.
|
||||
// It is useful for debugging, avoids the repeated generation of the binary.
|
||||
Assets string `toml:",omitempty"` |
||||
} |
@ -0,0 +1,305 @@ |
||||
// 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 go-bindata -nometadata -o assets.go -prefix assets -pkg dashboard assets/public/...
|
||||
|
||||
import ( |
||||
"fmt" |
||||
"io/ioutil" |
||||
"net" |
||||
"net/http" |
||||
"path/filepath" |
||||
"sync" |
||||
"sync/atomic" |
||||
"time" |
||||
|
||||
"github.com/ethereum/go-ethereum/log" |
||||
"github.com/ethereum/go-ethereum/p2p" |
||||
"github.com/ethereum/go-ethereum/rpc" |
||||
"github.com/rcrowley/go-metrics" |
||||
"golang.org/x/net/websocket" |
||||
) |
||||
|
||||
const ( |
||||
memorySampleLimit = 200 // Maximum number of memory data samples
|
||||
trafficSampleLimit = 200 // Maximum number of traffic data samples
|
||||
) |
||||
|
||||
var nextId uint32 // Next connection id
|
||||
|
||||
// Dashboard contains the dashboard internals.
|
||||
type Dashboard struct { |
||||
config *Config |
||||
|
||||
listener net.Listener |
||||
conns map[uint32]*client // Currently live websocket connections
|
||||
charts charts // The collected data samples to plot
|
||||
lock sync.RWMutex // Lock protecting the dashboard's internals
|
||||
|
||||
quit chan chan error // Channel used for graceful exit
|
||||
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 { |
||||
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
|
||||
} |
||||
|
||||
// charts contains the collected data samples.
|
||||
type charts struct { |
||||
Memory []*chartEntry `json:"memorySamples,omitempty"` |
||||
Traffic []*chartEntry `json:"trafficSamples,omitempty"` |
||||
} |
||||
|
||||
// chartEntry represents one data sample
|
||||
type chartEntry struct { |
||||
Time time.Time `json:"time,omitempty"` |
||||
Value float64 `json:"value,omitempty"` |
||||
} |
||||
|
||||
// New creates a new dashboard instance with the given configuration.
|
||||
func New(config *Config) (*Dashboard, error) { |
||||
return &Dashboard{ |
||||
conns: make(map[uint32]*client), |
||||
config: config, |
||||
quit: make(chan chan error), |
||||
}, nil |
||||
} |
||||
|
||||
// Protocols is a meaningless implementation of node.Service.
|
||||
func (db *Dashboard) Protocols() []p2p.Protocol { return nil } |
||||
|
||||
// APIs is a meaningless implementation of node.Service.
|
||||
func (db *Dashboard) APIs() []rpc.API { return nil } |
||||
|
||||
// Start implements node.Service, starting the data collection thread and the listening server of the dashboard.
|
||||
func (db *Dashboard) Start(server *p2p.Server) error { |
||||
db.wg.Add(2) |
||||
go db.collectData() |
||||
go db.collectLogs() // In case of removing this line change 2 back to 1 in wg.Add.
|
||||
|
||||
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 implements node.Service, stopping the data collection thread and the connection listener of the dashboard.
|
||||
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 < 2; 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 = "/dashboard.html" |
||||
} |
||||
// If the path of the assets is manually set
|
||||
if db.config.Assets != "" { |
||||
blob, err := ioutil.ReadFile(filepath.Join(db.config.Assets, path)) |
||||
if err != nil { |
||||
log.Warn("Failed to read file", "path", path, "err", err) |
||||
http.Error(w, "not found", http.StatusNotFound) |
||||
return |
||||
} |
||||
w.Write(blob) |
||||
return |
||||
} |
||||
blob, err := Asset(filepath.Join("public", path)) |
||||
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(&nextId, 1) |
||||
client := &client{ |
||||
conn: conn, |
||||
msg: make(chan message, 128), |
||||
logger: log.New("id", id), |
||||
} |
||||
done := make(chan struct{}) // Buffered channel as sender may exit early
|
||||
|
||||
// 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.
|
||||
client.msg <- message{ |
||||
History: &db.charts, |
||||
} |
||||
// 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 { |
||||
fail := []byte{} |
||||
if _, err := conn.Read(fail); err != nil { |
||||
close(done) |
||||
return |
||||
} |
||||
// Ignore all messages
|
||||
} |
||||
} |
||||
|
||||
// collectData collects the required data to plot on the dashboard.
|
||||
func (db *Dashboard) collectData() { |
||||
defer db.wg.Done() |
||||
|
||||
for { |
||||
select { |
||||
case errc := <-db.quit: |
||||
errc <- nil |
||||
return |
||||
case <-time.After(db.config.Refresh): |
||||
inboundTraffic := metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Rate1() |
||||
memoryInUse := metrics.DefaultRegistry.Get("system/memory/inuse").(metrics.Meter).Rate1() |
||||
now := time.Now() |
||||
memory := &chartEntry{ |
||||
Time: now, |
||||
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) |
||||
|
||||
db.sendToAll(&message{ |
||||
Memory: memory, |
||||
Traffic: traffic, |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
|
||||
// collectLogs collects and sends the logs to the active dashboards.
|
||||
func (db *Dashboard) collectLogs() { |
||||
defer db.wg.Done() |
||||
|
||||
// TODO (kurkomisi): log collection comes here.
|
||||
for { |
||||
select { |
||||
case errc := <-db.quit: |
||||
errc <- nil |
||||
return |
||||
case <-time.After(db.config.Refresh / 2): |
||||
db.sendToAll(&message{ |
||||
Log: "This is a fake log.", |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
|
||||
// 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() |
||||
} |
Loading…
Reference in new issue