cmd, dashboard: dashboard using React, Material-UI, Recharts

This commit is contained in:
Kurkó Mihály 2017-10-27 20:30:44 +03:00
parent a3128f9099
commit 523ff69b23
21 changed files with 248858 additions and 7 deletions

3
.gitignore vendored
View file

@ -33,3 +33,6 @@ profile.cov
# IdeaIDE # IdeaIDE
.idea .idea
# dashboard
/dashboard/assets/node_modules

View file

@ -30,6 +30,7 @@ import (
"github.com/ethereum/go-ethereum/cmd/utils" "github.com/ethereum/go-ethereum/cmd/utils"
"github.com/ethereum/go-ethereum/contracts/release" "github.com/ethereum/go-ethereum/contracts/release"
"github.com/ethereum/go-ethereum/dashboard"
"github.com/ethereum/go-ethereum/eth" "github.com/ethereum/go-ethereum/eth"
"github.com/ethereum/go-ethereum/node" "github.com/ethereum/go-ethereum/node"
"github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/params"
@ -76,10 +77,11 @@ type ethstatsConfig struct {
} }
type gethConfig struct { type gethConfig struct {
Eth eth.Config Eth eth.Config
Shh whisper.Config Shh whisper.Config
Node node.Config Node node.Config
Ethstats ethstatsConfig Ethstats ethstatsConfig
Dashboard dashboard.Config
} }
func loadConfig(file string, cfg *gethConfig) error { func loadConfig(file string, cfg *gethConfig) error {
@ -110,9 +112,10 @@ func defaultNodeConfig() node.Config {
func makeConfigNode(ctx *cli.Context) (*node.Node, gethConfig) { func makeConfigNode(ctx *cli.Context) (*node.Node, gethConfig) {
// Load defaults. // Load defaults.
cfg := gethConfig{ cfg := gethConfig{
Eth: eth.DefaultConfig, Eth: eth.DefaultConfig,
Shh: whisper.DefaultConfig, Shh: whisper.DefaultConfig,
Node: defaultNodeConfig(), Node: defaultNodeConfig(),
Dashboard: dashboard.DefaultConfig,
} }
// Load config file. // Load config file.
@ -134,6 +137,7 @@ func makeConfigNode(ctx *cli.Context) (*node.Node, gethConfig) {
} }
utils.SetShhConfig(ctx, stack, &cfg.Shh) utils.SetShhConfig(ctx, stack, &cfg.Shh)
utils.SetDashboardConfig(ctx, &cfg.Dashboard)
return stack, cfg return stack, cfg
} }
@ -153,6 +157,9 @@ func makeFullNode(ctx *cli.Context) *node.Node {
utils.RegisterEthService(stack, &cfg.Eth) utils.RegisterEthService(stack, &cfg.Eth)
if ctx.GlobalBool(utils.DashboardEnabledFlag.Name) {
utils.RegisterDashboardService(stack, &cfg.Dashboard)
}
// Whisper must be explicitly enabled by specifying at least 1 whisper flag or in dev mode // Whisper must be explicitly enabled by specifying at least 1 whisper flag or in dev mode
shhEnabled := enableWhisper(ctx) shhEnabled := enableWhisper(ctx)
shhAutoEnabled := !ctx.GlobalIsSet(utils.WhisperEnabledFlag.Name) && ctx.GlobalIsSet(utils.DeveloperFlag.Name) shhAutoEnabled := !ctx.GlobalIsSet(utils.WhisperEnabledFlag.Name) && ctx.GlobalIsSet(utils.DeveloperFlag.Name)

View file

@ -61,6 +61,11 @@ var (
utils.DataDirFlag, utils.DataDirFlag,
utils.KeyStoreDirFlag, utils.KeyStoreDirFlag,
utils.NoUSBFlag, utils.NoUSBFlag,
utils.DashboardEnabledFlag,
utils.DashboardAddrFlag,
utils.DashboardPortFlag,
utils.DashboardRefreshFlag,
utils.DashboardAssetsFlag,
utils.EthashCacheDirFlag, utils.EthashCacheDirFlag,
utils.EthashCachesInMemoryFlag, utils.EthashCachesInMemoryFlag,
utils.EthashCachesOnDiskFlag, utils.EthashCachesOnDiskFlag,

View file

@ -25,6 +25,7 @@ import (
"github.com/ethereum/go-ethereum/cmd/utils" "github.com/ethereum/go-ethereum/cmd/utils"
"github.com/ethereum/go-ethereum/internal/debug" "github.com/ethereum/go-ethereum/internal/debug"
"gopkg.in/urfave/cli.v1" "gopkg.in/urfave/cli.v1"
"strings"
) )
// AppHelpTemplate is the test template for the default, global app help topic. // AppHelpTemplate is the test template for the default, global app help topic.
@ -97,6 +98,16 @@ var AppHelpFlagGroups = []flagGroup{
utils.EthashDatasetsOnDiskFlag, utils.EthashDatasetsOnDiskFlag,
}, },
}, },
//{
// Name: "DASHBOARD",
// Flags: []cli.Flag{
// utils.DashboardEnabledFlag,
// utils.DashboardAddrFlag,
// utils.DashboardPortFlag,
// utils.DashboardRefreshFlag,
// utils.DashboardAssetsFlag,
// },
//},
{ {
Name: "TRANSACTION POOL", Name: "TRANSACTION POOL",
Flags: []cli.Flag{ Flags: []cli.Flag{
@ -268,6 +279,9 @@ func init() {
uncategorized := []cli.Flag{} uncategorized := []cli.Flag{}
for _, flag := range data.(*cli.App).Flags { for _, flag := range data.(*cli.App).Flags {
if _, ok := categorized[flag.String()]; !ok { if _, ok := categorized[flag.String()]; !ok {
if strings.HasPrefix(flag.GetName(), "dashboard") {
continue
}
uncategorized = append(uncategorized, flag) uncategorized = append(uncategorized, flag)
} }
} }

View file

@ -38,6 +38,7 @@ import (
"github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/dashboard"
"github.com/ethereum/go-ethereum/eth" "github.com/ethereum/go-ethereum/eth"
"github.com/ethereum/go-ethereum/eth/downloader" "github.com/ethereum/go-ethereum/eth/downloader"
"github.com/ethereum/go-ethereum/eth/gasprice" "github.com/ethereum/go-ethereum/eth/gasprice"
@ -183,6 +184,31 @@ var (
Name: "lightkdf", Name: "lightkdf",
Usage: "Reduce key-derivation RAM & CPU usage at some expense of KDF strength", Usage: "Reduce key-derivation RAM & CPU usage at some expense of KDF strength",
} }
// Dashboard settings
DashboardEnabledFlag = cli.BoolFlag{
Name: "dashboard",
Usage: "Enable the dashboard",
}
DashboardAddrFlag = cli.StringFlag{
Name: "dashboard.addr",
Usage: "Dashboard listening interface",
Value: dashboard.DefaultConfig.Host,
}
DashboardPortFlag = cli.IntFlag{
Name: "dashboard.host",
Usage: "Dashboard listening port",
Value: dashboard.DefaultConfig.Port,
}
DashboardRefreshFlag = cli.DurationFlag{
Name: "dashboard.refresh",
Usage: "Dashboard metrics collection refresh rate",
Value: dashboard.DefaultConfig.Refresh,
}
DashboardAssetsFlag = cli.StringFlag{
Name: "dashboard.assets",
Usage: "Developer flag to serve the dashboard from the local file system (default: \"\")",
Value: dashboard.DefaultConfig.Assets,
}
// Ethash settings // Ethash settings
EthashCacheDirFlag = DirectoryFlag{ EthashCacheDirFlag = DirectoryFlag{
Name: "ethash.cachedir", Name: "ethash.cachedir",
@ -1019,6 +1045,14 @@ func SetEthConfig(ctx *cli.Context, stack *node.Node, cfg *eth.Config) {
} }
} }
// SetDashboardConfig applies dashboard related command line flags to the config.
func SetDashboardConfig(ctx *cli.Context, cfg *dashboard.Config) {
cfg.Host = ctx.GlobalString(DashboardAddrFlag.Name)
cfg.Port = ctx.GlobalInt(DashboardPortFlag.Name)
cfg.Refresh = ctx.GlobalDuration(DashboardRefreshFlag.Name)
cfg.Assets = ctx.GlobalString(DashboardAssetsFlag.Name)
}
// RegisterEthService adds an Ethereum client to the stack. // RegisterEthService adds an Ethereum client to the stack.
func RegisterEthService(stack *node.Node, cfg *eth.Config) { func RegisterEthService(stack *node.Node, cfg *eth.Config) {
var err error var err error
@ -1041,6 +1075,13 @@ func RegisterEthService(stack *node.Node, cfg *eth.Config) {
} }
} }
// RegisterDashboardService adds a dashboard to the stack.
func RegisterDashboardService(stack *node.Node, cfg *dashboard.Config) {
stack.Register(func(ctx *node.ServiceContext) (node.Service, error) {
return dashboard.New(cfg)
})
}
// RegisterShhService configures Whisper and adds it to the given node. // RegisterShhService configures Whisper and adds it to the given node.
func RegisterShhService(stack *node.Node, cfg *whisper.Config) { func RegisterShhService(stack *node.Node, cfg *whisper.Config) {
if err := stack.Register(func(n *node.ServiceContext) (node.Service, error) { if err := stack.Register(func(n *node.ServiceContext) (node.Service, error) {

62
dashboard/README.md Normal file
View file

@ -0,0 +1,62 @@
## 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 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 waits for update messages, updates the content and tries to reconnect on connection loss.
### Users
#### Installation steps
1. `cd .../go-ethereum/`
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. Run the server with `geth --rinkeby --dashboard --vmodule=dashboard=5 --metrics console`.
* 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
[Webpack][Webpack] offers great tools for visualizing the bundle's dependency tree and space usage.
* Generate the bundle's profile by 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/

260
dashboard/assets.go Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,31 @@
// 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
}
}

View file

@ -0,0 +1,29 @@
// isNullOrUndefined returns true if the given variable is null or 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 defaultZero = elem => isNullOrUndefined(elem) ? 0 : elem;
export const MEMORY_SAMPLE_LIMIT = 200; // Maximum number of memory data samples.
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.
export const TAGS = (() => {
const T = {
home: { title: "Home", },
logs: { title: "Logs", },
networking: { title: "Networking", },
txpool: { title: "Txpool", },
blockchain: { title: "Blockchain", },
system: { title: "System", },
};
// 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;
})();
// Temporary - taken from Material-UI
export const DRAWER_WIDTH = 240;

View file

@ -0,0 +1,146 @@
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, defaultZero, MEMORY_SAMPLE_LIMIT, TAGS} from "./Common.jsx";
// Styles for the Dashboard component.
const styles = theme => ({
appFrame: {
position: 'relative',
display: 'flex',
width: '100%',
height: '100%',
background: '#303030',
},
});
// 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: [],
};
}
// 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("ws://" + 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 => {
// (Re)initialize the state with the past data. metrics is set only in the first msg,
// after the connection is established.
if (!isNullOrUndefined(msg.metrics)) {
let memory = [];
let traffic = [];
if (!isNullOrUndefined(msg.metrics.memory)) {
memory = msg.metrics.memory.map(elem => ({memory: elem.value}));
traffic = msg.metrics.processor.map(elem => ({traffic: defaultZero(elem.value)})) // TODO (kurkomisi): traffic != processor!!!
}
this.setState({
memory: memory,
traffic: traffic,
logs: [],
});
}
// Insert the new data samples.
isNullOrUndefined(msg.memory) || this.setState(prevState => {
let memory = prevState.memory;
let traffic = prevState.traffic;
// Remove the first elements in case the samples' amount exceeds the limit.
if (memory.length === MEMORY_SAMPLE_LIMIT) {
memory.shift();
traffic.shift();
}
return ({
memory: [...memory, {memory: msg.memory.value}],
traffic: [...traffic, {traffic: defaultZero(msg.processor.value)}],
});
});
// Insert the new log.
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.
changeContent = active => {
this.state.active === active || this.setState({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}
/>
</div>
);
}
}
Dashboard.propTypes = {
classes: PropTypes.object.isRequired,
};
export default withStyles(styles)(Dashboard);

View file

@ -0,0 +1,71 @@
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);

View file

@ -0,0 +1,158 @@
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
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";
// 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.
class ContentSwitch extends Component {
render() {
switch(this.props.active) {
case TAGS.home.id:
return (
<ChartGrid spacing={24}>
<AreaChart xs={6} height={300} data={this.props.memory}>
<YAxis />
<Area type="monotone" dataKey="memory" stroke="#8884d8" fill="#8884d8" />
</AreaChart>
<LineChart xs={6} height={300} data={this.props.traffic}>
<Line type="monotone" dataKey="traffic" dot={false} />
</LineChart>
<LineChart xs={6} height={300} data={this.props.memory}>
<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:
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;
}
}
ContentSwitch.propTypes = {
active: PropTypes.string.isRequired,
};
// 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}
/>
</main>
);
}
}
Main.propTypes = {
classes: PropTypes.object.isRequired,
opened: PropTypes.bool.isRequired,
active: PropTypes.string.isRequired,
};
export default withStyles(styles)(Main);

View file

@ -0,0 +1,89 @@
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] = e => {
e.preventDefault();
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);

View file

@ -0,0 +1,20 @@
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')
); // server-side rendering

View file

@ -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"
}
}

File diff suppressed because it is too large Load diff

View file

@ -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>

155819
dashboard/assets/stats.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,20 @@
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']
}
},
],
},
};

45
dashboard/config.go Normal file
View file

@ -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 data 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"`
}

314
dashboard/dashboard.go Normal file
View file

@ -0,0 +1,314 @@
// 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 (
"bytes"
"fmt"
"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"
"io/ioutil"
"net"
"net/http"
"sync"
"sync/atomic"
"time"
)
const (
processorSampleLimit = 200
memorySampleLimit = 200
trafficSampleLimit = 200
)
var (
nextId uint32 // Next connection id
)
type dashboard struct {
config *Config
listener net.Listener
conns map[uint32]*client // Currently live websocket connections
Metrics *metricSamples `json:"metrics,omitempty"`
Stats *status `json:"stats,omitempty"`
lock sync.RWMutex // Lock protecting the dashboard's internals
closing chan chan error // Channel used for graceful exit
wg sync.WaitGroup
}
type client struct {
conn *websocket.Conn // Particular live websocket connection
msg chan *map[string]interface{} // Message queue for the update messages
logger log.Logger // Logger for the particular live websocket connection
}
type metricSamples struct {
Processor []*data `json:"processor,omitempty"`
Memory []*data `json:"memory,omitempty"`
}
type data struct {
Time time.Time `json:"time,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.
func New(config *Config) (*dashboard, error) {
return &dashboard{
conns: make(map[uint32]*client),
config: config,
Metrics: &metricSamples{},
closing: 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 {
var err error
// Close the connection listener
if err = db.listener.Close(); err != nil {
log.Warn("Failed to close listener", "err", err)
}
errc := make(chan error)
db.closing <- errc // collectData
<-errc
db.closing <- errc // collectLogs
<-errc
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()
db.wg.Wait()
log.Info("Dashboard stopped")
return err
}
func join(strings ...string) *bytes.Buffer {
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.
func (db *dashboard) webHandler(w http.ResponseWriter, r *http.Request) {
log.Info("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 != "" {
file, err := ioutil.ReadFile(join(db.config.Assets, path).String())
if err != nil {
log.Warn("Failed to read file", "err", err)
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Write(file)
return
}
webapp, err := Asset(join("public", path).String())
if err != nil {
log.Warn("Failed to load the asset", "path", path, "err", err)
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Write(webapp)
}
// 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 *map[string]interface{}, 128),
logger: log.New("id", id),
}
loss := make(chan bool, 1) // 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 <-loss:
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)
return
}
}
}
}()
// Send the past data.
client.msg <- &map[string]interface{}{
"metrics": db.Metrics,
}
// 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 {
loss <- true
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.closing:
errc <- nil
return
case <-time.After(db.config.Refresh):
now := time.Now()
traffic := metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Rate1()
memoryInUse := metrics.DefaultRegistry.Get("system/memory/inuse").(metrics.Meter).Rate1()
traff := &data{
Time: now,
Value: traffic,
}
memory := &data{
Time: now,
Value: memoryInUse,
}
// TODO (kurkomisi): do not mix traffic with processor!
db.update(traff, memory)
}
}
}
// 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.closing:
errc <- nil
return
case <-time.After(db.config.Refresh):
db.sendToAll(&map[string]interface{}{
"log": "This is a fake log.",
})
}
}
}
// update updates the dashboards through the live websocket connections.
func (db *dashboard) update(processor *data, memory *data) {
// 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()
for _, c := range db.conns {
select {
case c.msg <- msg:
default:
c.logger.Warn("Client message queue is full")
}
}
db.lock.Unlock()
}