dashboard: memory, traffic and CPU on footer

This commit is contained in:
Kurkó Mihály 2018-01-22 23:12:58 +02:00
parent 7ba7418658
commit bd03f0a0ca
20 changed files with 1854 additions and 1232 deletions

File diff suppressed because one or more lines are too long

View file

@ -40,6 +40,9 @@
'react/jsx-indent': ['error', 'tab'], 'react/jsx-indent': ['error', 'tab'],
'react/jsx-indent-props': ['error', 'tab'], 'react/jsx-indent-props': ['error', 'tab'],
'react/prefer-stateless-function': 'off', 'react/prefer-stateless-function': 'off',
'jsx-quotes': ['error', 'prefer-single'],
'no-plusplus': 'off',
'no-console': ['error', { allow: ['error'] }],
// Specifies the maximum length of a line. // Specifies the maximum length of a line.
'max-len': ['warn', 120, 2, { 'max-len': ['warn', 120, 2, {
@ -49,7 +52,7 @@
'ignoreStrings': true, 'ignoreStrings': true,
'ignoreTemplateLiterals': true, 'ignoreTemplateLiterals': true,
}], }],
// Enforces spacing between keys and values in object literal properties. // Enforces consistent spacing between keys and values in object literal properties.
'key-spacing': ['error', {'align': { 'key-spacing': ['error', {'align': {
'beforeColon': false, 'beforeColon': false,
'afterColon': true, 'afterColon': true,

View file

@ -63,3 +63,9 @@ export type MenuProp = {|...ProvidedMenuProp, id: string|};
export const MENU: Map<string, {...MenuProp}> = new Map(menuSkeletons.map(({id, menu}) => ([id, {id, ...menu}]))); export const MENU: Map<string, {...MenuProp}> = new Map(menuSkeletons.map(({id, menu}) => ([id, {id, ...menu}])));
export const DURATION = 200; export const DURATION = 200;
export const styles = {
light: {
color: 'rgba(255, 255, 255, 0.54)',
},
}

View file

@ -18,35 +18,32 @@
import React, {Component} from 'react'; import React, {Component} from 'react';
import withStyles from 'material-ui/styles/withStyles';
import SideBar from './SideBar'; import SideBar from './SideBar';
import Main from './Main'; import Main from './Main';
import type {Content} from '../types/content'; import type {Content} from '../types/content';
// Styles for the Body component. // styles contains the constant styles of the component.
const styles = () => ({ const styles = {
body: { body: {
display: 'flex', display: 'flex',
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
}); };
export type Props = { export type Props = {
classes: Object,
opened: boolean, opened: boolean,
changeContent: () => {}, changeContent: string => void,
active: string, active: string,
content: Content, content: Content,
shouldUpdate: Object, shouldUpdate: Object,
}; };
// Body renders the body of the dashboard. // Body renders the body of the dashboard.
class Body extends Component<Props> { class Body extends Component<Props> {
render() { render() {
const {classes} = this.props; // The classes property is injected by withStyles().
return ( return (
<div className={classes.body}> <div style={styles.body}>
<SideBar <SideBar
opened={this.props.opened} opened={this.props.opened}
changeContent={this.props.changeContent} changeContent={this.props.changeContent}
@ -61,4 +58,4 @@ class Body extends Component<Props> {
} }
} }
export default withStyles(styles)(Body); export default Body;

View file

@ -35,7 +35,7 @@ class ChartGrid extends Component<Props> {
{ {
React.Children.map(this.props.children, child => ( React.Children.map(this.props.children, child => (
<Grid item xs={child.props.xs}> <Grid item xs={child.props.xs}>
<ResponsiveContainer width="100%" height={child.props.height}> <ResponsiveContainer width='100%' height={child.props.height}>
{React.cloneElement(child, {data: child.props.values.map(value => ({value}))})} {React.cloneElement(child, {data: child.props.values.map(value => ({value}))})}
</ResponsiveContainer> </ResponsiveContainer>
</Grid> </Grid>

View file

@ -0,0 +1,57 @@
// @flow
// Copyright 2018 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import React, {Component} from 'react';
import type {ChildrenArray} from 'react';
import Grid from 'material-ui/Grid';
// styles contains the constant styles of the component.
const styles = {
container: {
flexWrap: 'nowrap',
height: '100%',
maxWidth: '100%',
margin: 0,
},
item: {
flex: 1,
padding: 0,
},
}
export type Props = {
children: ChildrenArray<React$Element<any>>,
};
// ChartRow renders a row of equally sized responsive charts.
class ChartRow extends Component<Props> {
render() {
return (
<Grid container direction='row' style={styles.container} justify='space-between'>
{React.Children.map(this.props.children, child => (
<Grid item xs style={styles.item}>
{child}
</Grid>
))}
</Grid>
);
}
}
export default ChartRow;

View file

@ -0,0 +1,75 @@
// @flow
// Copyright 2018 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import React, {Component} from 'react';
import Typography from 'material-ui/Typography';
import {styles} from '../common';
// multiplier multiplies a number by another.
export const multiplier = <T>(by: number = 1) => (x: number) => x * by;
// valuePlotter renders a tooltip, which shows the value of the payload.
export const valuePlotter = <T>(text: string, mapper: (T => T) = multiplier(1)) => (payload: T) => {
const p = mapper(payload);
return (
<Typography type='caption' color='inherit'>
<span style={styles.light}>{text}</span> {p}
</Typography>
);
};
// unit contains the units for the bytePlotter.
const unit = ['B', 'kB', 'MB', 'GB', 'TB', 'PB'];
// bytePlotter renders a tooltip, which shows the simplified byte value of the payload followed by the unit.
export const bytePlotter = <T>(text: string, mapper: (T => T) = multiplier(1)) => (payload: T) => {
let p = mapper(payload);
if (typeof p !== 'number') {
return null;
}
let i = 0;
for (; p > 1024 && i < 5; i++) {
p /= 1024;
}
return (
<Typography type='caption' color='inherit'>
<span style={styles.light}>{text}</span> {p.toFixed(2)} {unit[i]}
</Typography>
);
};
export type Props = {
active: boolean,
payload: Object,
tooltip: <T>(text: string, mapper?: T => T) => (payload: mixed) => null | React$Element<any>,
};
// CustomTooltip takes a tooltip function, and uses it to plot the active value of the chart.
class CustomTooltip extends Component<Props> {
render() {
const {active, payload, tooltip} = this.props;
if (!active || typeof tooltip !== 'function') {
return null;
}
return tooltip(payload[0].value);
}
}
export default CustomTooltip;

View file

@ -22,8 +22,7 @@ import withStyles from 'material-ui/styles/withStyles';
import Header from './Header'; import Header from './Header';
import Body from './Body'; import Body from './Body';
import Footer from './Footer'; import {MENU} from '../common';
import {MENU} from './Common';
import type {Content} from '../types/content'; import type {Content} from '../types/content';
// deepUpdate updates an object corresponding to the given update data, which has // deepUpdate updates an object corresponding to the given update data, which has
@ -35,17 +34,17 @@ import type {Content} from '../types/content';
// the generalization of the message handling. The only necessary thing is to set a // the generalization of the message handling. The only necessary thing is to set a
// handler function for every path of the state in order to maximize the flexibility // handler function for every path of the state in order to maximize the flexibility
// of the update. // of the update.
const deepUpdate = (prev: Object, update: Object, updater: Object) => { const deepUpdate = (updater: Object, update: Object, prev: Object): $Shape<Content> => {
if (typeof update === 'undefined') { if (typeof update === 'undefined') {
// TODO (kurkomisi): originally this was deep copy, investigate it. // TODO (kurkomisi): originally this was deep copy, investigate it.
return prev; return prev;
} }
if (typeof updater === 'function') { if (typeof updater === 'function') {
return updater(prev, update); return updater(update, prev);
} }
const updated = {}; const updated = {};
Object.keys(prev).forEach((key) => { Object.keys(prev).forEach((key) => {
updated[key] = deepUpdate(prev[key], update[key], updater[key]); updated[key] = deepUpdate(updater[key], update[key], prev[key]);
}); });
return updated; return updated;
@ -56,21 +55,25 @@ const deepUpdate = (prev: Object, update: Object, updater: Object) => {
// whether the involved data was changed or not by checking the message structure. // whether the involved data was changed or not by checking the message structure.
// //
// We could return the message itself too, but it's safer not to give access to it. // We could return the message itself too, but it's safer not to give access to it.
const shouldUpdate = (msg: Object, updater: Object) => { const shouldUpdate = (updater: Object, msg: Object) => {
const su = {}; const su = {};
Object.keys(msg).forEach((key) => { Object.keys(msg).forEach((key) => {
su[key] = typeof updater[key] !== 'function' ? shouldUpdate(msg[key], updater[key]) : true; su[key] = typeof updater[key] !== 'function' ? shouldUpdate(updater[key], msg[key]) : true;
}); });
return su; return su;
}; };
// appender is a state update generalization function, which appends the update data // replacer is a state updater function, which replaces the original data.
// to the existing data. limit defines the maximum allowed size of the created array. const replacer = <T>(update: T) => update;
const appender = <T>(limit: number) => (prev: Array<T>, update: Array<T>) => [...prev, ...update].slice(-limit);
// replacer is a state update generalization function, which replaces the original data. // appender is a state updater function, which appends the update data to the
const replacer = <T>(prev: T, update: T) => update; // existing data. limit defines the maximum allowed size of the created array,
// mapper maps the update data.
const appender = <T>(limit: number, mapper = replacer) => (update: Array<T>, prev: Array<T>) => [
...prev,
...update.map(sample => mapper(sample)),
].slice(-limit);
// defaultContent is the initial value of the state content. // defaultContent is the initial value of the state content.
const defaultContent: Content = { const defaultContent: Content = {
@ -79,8 +82,11 @@ const defaultContent: Content = {
commit: null, commit: null,
}, },
home: { home: {
memory: [], activeMemory: [],
traffic: [], virtualMemory: [],
ingress: [],
egress: [],
cpu: [],
}, },
chain: {}, chain: {},
txpool: {}, txpool: {},
@ -91,16 +97,20 @@ const defaultContent: Content = {
}, },
}; };
// updaters contains the state update generalization functions for each path of the state. // updaters contains the state updater functions for each path of the state.
// TODO (kurkomisi): Define a tricky type which embraces the content and the handlers. //
// TODO (kurkomisi): Define a tricky type which embraces the content and the updaters.
const updaters = { const updaters = {
general: { general: {
version: replacer, version: replacer,
commit: replacer, commit: replacer,
}, },
home: { home: {
memory: appender(200), activeMemory: appender(200),
traffic: appender(200), virtualMemory: appender(200),
ingress: appender(200),
egress: appender(200),
cpu: appender(200),
}, },
chain: null, chain: null,
txpool: null, txpool: null,
@ -111,28 +121,34 @@ const updaters = {
}, },
}; };
// styles returns the styles for the Dashboard component. // styles contains the constant styles of the component.
const styles = theme => ({ const styles = {
dashboard: { dashboard: {
display: 'flex', display: 'flex',
flexFlow: 'column', flexFlow: 'column',
width: '100%', width: '100%',
height: '100%', height: '100%',
background: theme.palette.background.default,
zIndex: 1, zIndex: 1,
overflow: 'hidden', overflow: 'hidden',
}
};
// themeStyles returns the styles generated from the theme for the component.
const themeStyles: Object = (theme: Object) => ({
dashboard: {
background: theme.palette.background.default,
}, },
}); });
export type Props = { export type Props = {
classes: Object, classes: Object, // injected by withStyles()
}; };
type State = { type State = {
active: string, // active menu active: string, // active menu
sideBar: boolean, // true if the sidebar is opened sideBar: boolean, // true if the sidebar is opened
content: Content, // the visualized data content: Content, // the visualized data
shouldUpdate: Object // labels for the components, which need to rerender based on the incoming message shouldUpdate: Object, // labels for the components, which need to re-render based on the incoming message
}; };
// Dashboard is the main component, which renders the whole page, makes connection with the server and // Dashboard is the main component, which renders the whole page, makes connection with the server and
@ -176,8 +192,8 @@ class Dashboard extends Component<Props, State> {
// update updates the content corresponding to the incoming message. // update updates the content corresponding to the incoming message.
update = (msg: $Shape<Content>) => { update = (msg: $Shape<Content>) => {
this.setState(prevState => ({ this.setState(prevState => ({
content: deepUpdate(prevState.content, msg, updaters), content: deepUpdate(updaters, msg, prevState.content),
shouldUpdate: shouldUpdate(msg, updaters), shouldUpdate: shouldUpdate(updaters, msg),
})); }));
}; };
@ -186,25 +202,17 @@ class Dashboard extends Component<Props, State> {
this.setState(prevState => (prevState.active !== newActive ? {active: newActive} : {})); this.setState(prevState => (prevState.active !== newActive ? {active: newActive} : {}));
}; };
// openSideBar opens the sidebar. // switchSideBar opens or closes the sidebar's state.
openSideBar = () => { switchSideBar = () => {
this.setState({sideBar: true}); this.setState(prevState => ({sideBar: !prevState.sideBar}));
};
// closeSideBar closes the sidebar.
closeSideBar = () => {
this.setState({sideBar: false});
}; };
render() { render() {
const {classes} = this.props; // The classes property is injected by withStyles().
return ( return (
<div className={classes.dashboard}> <div className={this.props.classes.dashboard} style={styles.dashboard}>
<Header <Header
opened={this.state.sideBar} opened={this.state.sideBar}
openSideBar={this.openSideBar} switchSideBar={this.switchSideBar}
closeSideBar={this.closeSideBar}
/> />
<Body <Body
opened={this.state.sideBar} opened={this.state.sideBar}
@ -213,16 +221,9 @@ class Dashboard extends Component<Props, State> {
content={this.state.content} content={this.state.content}
shouldUpdate={this.state.shouldUpdate} shouldUpdate={this.state.shouldUpdate}
/> />
<Footer
opened={this.state.sideBar}
openSideBar={this.openSideBar}
closeSideBar={this.closeSideBar}
general={this.state.content.general}
shouldUpdate={this.state.shouldUpdate}
/>
</div> </div>
); );
} }
} }
export default withStyles(styles)(Dashboard); export default withStyles(themeStyles)(Dashboard);

View file

@ -19,45 +19,117 @@
import React, {Component} from 'react'; import React, {Component} from 'react';
import withStyles from 'material-ui/styles/withStyles'; import withStyles from 'material-ui/styles/withStyles';
import AppBar from 'material-ui/AppBar';
import Toolbar from 'material-ui/Toolbar';
import Typography from 'material-ui/Typography'; import Typography from 'material-ui/Typography';
import Grid from 'material-ui/Grid';
import {ResponsiveContainer, AreaChart, Area, Tooltip} from 'recharts';
import type {General} from '../types/content'; import ChartRow from './ChartRow';
import CustomTooltip, {bytePlotter, valuePlotter, multiplier} from './CustomTooltip';
import {styles as commonStyles} from '../common';
import type {Content} from '../types/content';
// styles contains styles for the Header component. // styles contains the constant styles of the component.
const styles = theme => ({ const styles = {
footer: {
maxWidth: '100%',
flexWrap: 'nowrap',
margin: 0,
},
chartRowWrapper: {
height: '100%',
padding: 0,
},
doubleChartWrapper: {
height: '100%',
width: '99%',
paddingTop: 5,
},
};
// themeStyles returns the styles generated from the theme for the component.
const themeStyles: Object = (theme: Object) => ({
footer: { footer: {
backgroundColor: theme.palette.background.appBar, backgroundColor: theme.palette.background.appBar,
color: theme.palette.getContrastText(theme.palette.background.appBar), color: theme.palette.getContrastText(theme.palette.background.appBar),
zIndex: theme.zIndex.appBar, zIndex: theme.zIndex.appBar,
}, height: theme.spacing.unit * 10,
toolbar: {
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
display: 'flex',
justifyContent: 'flex-end',
},
light: {
color: 'rgba(255, 255, 255, 0.54)',
}, },
}); });
export type Props = { export type Props = {
general: General, classes: Object, // injected by withStyles()
classes: Object, theme: Object,
content: Content,
shouldUpdate: Object,
}; };
// TODO (kurkomisi): If the structure is appropriate, make an abstraction of the common parts with the Header.
// Footer renders the header of the dashboard. // Footer renders the footer of the dashboard.
class Footer extends Component<Props> { class Footer extends Component<Props> {
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
return typeof nextProps.shouldUpdate.logs !== 'undefined'; return typeof nextProps.shouldUpdate.home !== 'undefined';
} }
info = (about: string, data: string) => ( // info renders a label with the given values.
<Typography type="caption" color="inherit"> info = (about: string, value: ?string) => (value ? (
<span className={this.props.classes.light}>{about}</span> {data} <Typography type='caption' color='inherit'>
<span style={commonStyles.light}>{about}</span> {value}
</Typography> </Typography>
) : null);
// doubleChart renders a pair of charts separated by the baseline.
doubleChart = (syncId, topChart, bottomChart) => {
const topKey = 'topKey';
const bottomKey = 'bottomKey';
const topDefault = topChart.default ? topChart.default : 0;
const bottomDefault = bottomChart.default ? bottomChart.default : 0;
const topTooltip = topChart.tooltip ? (
<Tooltip cursor={false} content={<CustomTooltip tooltip={topChart.tooltip} />} />
) : null;
const bottomTooltip = bottomChart.tooltip ? (
<Tooltip cursor={false} content={<CustomTooltip tooltip={bottomChart.tooltip} />} />
) : null;
const topColor = '#8884d8';
const bottomColor = '#82ca9d';
// Put the samples of the two charts into the same array in order to avoid problems
// at the synchronized area charts. If one of the two arrays doesn't have value at
// a given position, give it a 0 default value.
let data = [...topChart.data.map(({value}) => {
const d = {};
d[topKey] = value || topDefault;
return d;
})];
for (let i = 0; i < data.length && i < bottomChart.data.length; i++) {
// The value needs to be negative in order to plot it upside down.
const d = bottomChart.data[i];
data[i][bottomKey] = d && d.value ? -d.value : bottomDefault;
}
data = [...data, ...bottomChart.data.slice(data.length).map(({value}) => {
const d = {};
d[topKey] = topDefault;
d[bottomKey] = -value || bottomDefault;
return d;
})];
return (
<div style={styles.doubleChartWrapper}>
<ResponsiveContainer width='100%' height='50%'>
<AreaChart data={data} syncId={syncId} >
{topTooltip}
<Area type='monotone' dataKey={topKey} stroke={topColor} fill={topColor} />
</AreaChart>
</ResponsiveContainer>
<div style={{marginTop: -10, width: '100%', height: '50%'}}>
<ResponsiveContainer width='100%' height='100%'>
<AreaChart data={data} syncId={syncId} >
{bottomTooltip}
<Area type='monotone' dataKey={bottomKey} stroke={bottomColor} fill={bottomColor} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
); );
}
render() { render() {
const {classes, general} = this.props; // The classes property is injected by withStyles(). const {classes, general} = this.props; // The classes property is injected by withStyles().
@ -65,16 +137,33 @@ class Footer extends Component<Props> {
const commit = general.commit ? this.info('Commit', general.commit.substring(0, 7)) : null; const commit = general.commit ? this.info('Commit', general.commit.substring(0, 7)) : null;
return ( return (
<AppBar position="static" className={classes.footer}> <Grid container className={this.props.classes.footer} direction='row' alignItems='center' style={styles.footer}>
<Toolbar className={classes.toolbar}> <Grid item xs style={styles.chartRowWrapper}>
<div> <ChartRow>
{geth} {this.doubleChart(
{commit} 'all',
</div> {data: home.cpu, tooltip: valuePlotter('CPU')},
</Toolbar> {data: home.cpu, tooltip: valuePlotter('CPU', multiplier(-1))},
</AppBar> )}
{this.doubleChart(
'all',
{data: home.activeMemory, tooltip: bytePlotter('Active')},
{data: home.virtualMemory, tooltip: bytePlotter('Virtual', multiplier(-1))},
)}
{this.doubleChart(
'all',
{data: home.ingress, tooltip: bytePlotter('Download')},
{data: home.egress, tooltip: bytePlotter('Upload', multiplier(-1))},
)}
</ChartRow>
</Grid>
<Grid item >
{this.info('Geth', general.version)}
{this.info('Commit', general.commit ? general.commit.substring(0, 7) : null)}
</Grid>
</Grid>
); );
} }
} }
export default withStyles(styles)(Footer); export default withStyles(themeStyles)(Footer);

View file

@ -26,18 +26,22 @@ import IconButton from 'material-ui/IconButton';
import Typography from 'material-ui/Typography'; import Typography from 'material-ui/Typography';
import ChevronLeftIcon from 'material-ui-icons/ChevronLeft'; import ChevronLeftIcon from 'material-ui-icons/ChevronLeft';
import {DURATION} from './Common'; import {DURATION} from '../common';
// arrowDefault is the default style of the arrow button. // styles contains the constant styles of the component.
const arrowDefault = { const styles = {
arrow: {
default: {
transition: `transform ${DURATION}ms`, transition: `transform ${DURATION}ms`,
}; },
// arrowTransition is the additional style of the arrow button corresponding to the transition's state. transition: {
const arrowTransition = {
entered: {transform: 'rotate(180deg)'}, entered: {transform: 'rotate(180deg)'},
},
},
}; };
// Styles for the Header component.
const styles = theme => ({ // themeStyles returns the styles generated from the theme for the component.
const themeStyles = (theme: Object) => ({
header: { header: {
backgroundColor: theme.palette.background.appBar, backgroundColor: theme.palette.background.appBar,
color: theme.palette.getContrastText(theme.palette.background.appBar), color: theme.palette.getContrastText(theme.palette.background.appBar),
@ -47,53 +51,45 @@ const styles = theme => ({
paddingLeft: theme.spacing.unit, paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit, paddingRight: theme.spacing.unit,
}, },
mainText: { title: {
paddingLeft: theme.spacing.unit, paddingLeft: theme.spacing.unit,
}, },
}); });
export type Props = { export type Props = {
classes: Object, classes: Object, // injected by withStyles()
opened: boolean, opened: boolean,
openSideBar: () => {}, switchSideBar: () => void,
closeSideBar: () => {},
}; };
// Header renders the header of the dashboard. // Header renders the header of the dashboard.
class Header extends Component<Props> { class Header extends Component<Props> {
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
return nextProps.opened !== this.props.opened; return nextProps.opened !== this.props.opened;
} }
// changeSideBar opens or closes the sidebar corresponding to the previous state. // arrow renders a button, which changes the sidebar's state.
changeSideBar = () => { arrow = (transitionState: string) => (
if (this.props.opened) { <IconButton onClick={this.props.switchSideBar}>
this.props.closeSideBar();
} else {
this.props.openSideBar();
}
};
// arrowButton is connected to the sidebar; changes its state.
arrowButton = (transitionState: string) => (
<IconButton onClick={this.changeSideBar}>
<ChevronLeftIcon <ChevronLeftIcon
style={{ style={{
...arrowDefault, ...styles.arrow.default,
...arrowTransition[transitionState], ...styles.arrow.transition[transitionState],
}} }}
/> />
</IconButton> </IconButton>
); );
render() { render() {
const {classes, opened} = this.props; // The classes property is injected by withStyles(). const {classes, opened} = this.props;
return ( return (
<AppBar position="static" className={classes.header}> <AppBar position='static' className={classes.header}>
<Toolbar className={classes.toolbar}> <Toolbar className={classes.toolbar}>
<Transition mountOnEnter in={opened} timeout={{enter: DURATION}}> <Transition mountOnEnter in={opened} timeout={{enter: DURATION}}>
{this.arrowButton} {this.arrow}
</Transition> </Transition>
<Typography type="title" color="inherit" noWrap className={classes.mainText}> <Typography type='title' color='inherit' noWrap className={classes.title}>
Go Ethereum Dashboard Go Ethereum Dashboard
</Typography> </Typography>
</Toolbar> </Toolbar>
@ -102,4 +98,4 @@ class Header extends Component<Props> {
} }
} }
export default withStyles(styles)(Header); export default withStyles(themeStyles)(Header);

View file

@ -19,55 +19,50 @@
import React, {Component} from 'react'; import React, {Component} from 'react';
import withTheme from 'material-ui/styles/withTheme'; import withTheme from 'material-ui/styles/withTheme';
import {LineChart, AreaChart, Area, YAxis, CartesianGrid, Line} from 'recharts'; import {LineChart, AreaChart, Area, Line} from 'recharts';
import ChartGrid from './ChartGrid'; import ChartGrid from './ChartGrid';
import type {ChartEntry} from '../types/content'; import type {ChartEntry} from '../types/content';
export type Props = { export type Props = {
theme: Object, theme: Object,
memory: Array<ChartEntry>, activeMemory: Array<ChartEntry>,
traffic: Array<ChartEntry>, ingress: Array<ChartEntry>,
egress: Array<ChartEntry>,
cpu: Array<ChartEntry>,
shouldUpdate: Object, shouldUpdate: Object,
}; };
// Home renders the home content. // Home renders the home content.
class Home extends Component<Props> { class Home extends Component<Props> {
constructor(props: Props) {
super(props);
const {theme} = props; // The theme property is injected by withTheme().
this.memoryColor = theme.palette.primary[300];
this.trafficColor = theme.palette.secondary[300];
}
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
return typeof nextProps.shouldUpdate.home !== 'undefined'; return typeof nextProps.shouldUpdate.home !== 'undefined';
} }
memoryColor: Object;
trafficColor: Object;
render() { render() {
let {memory, traffic} = this.props; let {
memory = memory.map(({value}) => (value || 0)); activeMemory, ingress, egress, cpu
traffic = traffic.map(({value}) => (value || 0)); } = this.props;
activeMemory = activeMemory.map(({value}) => (value || 0));
cpu = cpu.map(({value}) => (value || 0));
ingress = ingress.map(({value}) => (value || 0));
egress = egress.map(({value}) => (value || 0));
const color = '#8884d8';
return ( return (
<ChartGrid spacing={24}> <ChartGrid spacing={24}>
<AreaChart xs={6} height={300} values={memory}> <AreaChart xs={6} height={300} values={activeMemory}>
<YAxis /> <Area type='monotone' dataKey='value' stroke={color} fill={color} />
<Area type="monotone" dataKey="value" stroke={this.memoryColor} fill={this.memoryColor} />
</AreaChart> </AreaChart>
<LineChart xs={6} height={300} values={traffic}> <LineChart xs={6} height={300} values={ingress}>
<Line type="monotone" dataKey="value" stroke={this.trafficColor} dot={false} /> <Line type='monotone' dataKey='value' stroke={color} dot={false} />
</LineChart> </LineChart>
<LineChart xs={6} height={300} values={memory}> <LineChart xs={6} height={300} values={cpu}>
<YAxis /> <Line type='monotone' dataKey='value' stroke={color} dot={false} />
<CartesianGrid stroke="#eee" strokeDasharray="5 5" />
<Line type="monotone" dataKey="value" stroke={this.memoryColor} dot={false} />
</LineChart> </LineChart>
<AreaChart xs={6} height={300} values={traffic}> <AreaChart xs={6} height={300} values={egress}>
<CartesianGrid stroke="#eee" strokeDasharray="5 5" vertical={false} /> <Area type='monotone' dataKey='value' stroke={color} fill={color} />
<Area type="monotone" dataKey="value" stroke={this.trafficColor} fill={this.trafficColor} />
</AreaChart> </AreaChart>
</ChartGrid> </ChartGrid>
); );

View file

@ -21,35 +21,58 @@ import React, {Component} from 'react';
import withStyles from 'material-ui/styles/withStyles'; import withStyles from 'material-ui/styles/withStyles';
import Home from './Home'; import Home from './Home';
import {MENU} from './Common'; import {MENU} from '../common';
import Footer from './Footer';
import type {Content} from '../types/content'; import type {Content} from '../types/content';
// Styles for the Content component. // styles contains the constant styles of the component.
const styles = theme => ({ const styles = {
wrapper: {
display: 'flex',
flexDirection: 'column',
width: '100%',
},
content: { content: {
flexGrow: 1, flex: 1,
backgroundColor: theme.palette.background.default,
padding: theme.spacing.unit * 3,
overflow: 'auto', overflow: 'auto',
}, },
};
// themeStyles returns the styles generated from the theme for the component.
const themeStyles = theme => ({
content: {
backgroundColor: theme.palette.background.default,
padding: theme.spacing.unit * 3,
},
}); });
export type Props = { export type Props = {
classes: Object, classes: Object,
active: string, active: string,
content: Content, content: Content,
shouldUpdate: Object, shouldUpdate: Object,
}; };
// Main renders the chosen content. // Main renders the chosen content.
class Main extends Component<Props> { class Main extends Component<Props> {
render() { render() {
const { const {
classes, active, content, shouldUpdate, classes, active, content, shouldUpdate,
} = this.props; } = this.props;
const {home} = content;
let children = null; let children = null;
switch (active) { switch (active) {
case MENU.get('home').id: case MENU.get('home').id:
children = <Home memory={content.home.memory} traffic={content.home.traffic} shouldUpdate={shouldUpdate} />; children = (
<Home
activeMemory={home.activeMemory}
ingress={home.ingress}
egress={home.egress}
cpu={home.cpu}
shouldUpdate={shouldUpdate}
/>
);
break; break;
case MENU.get('chain').id: case MENU.get('chain').id:
case MENU.get('txpool').id: case MENU.get('txpool').id:
@ -61,8 +84,16 @@ class Main extends Component<Props> {
children = <div>{content.logs.log.map((log, index) => <div key={index}>{log}</div>)}</div>; children = <div>{content.logs.log.map((log, index) => <div key={index}>{log}</div>)}</div>;
} }
return <div className={classes.content}>{children}</div>; return (
<div style={styles.wrapper}>
<div className={classes.content} style={styles.content}>{children}</div>
<Footer
content={content}
shouldUpdate={shouldUpdate}
/>
</div>
);
} }
} }
export default withStyles(styles)(Main); export default withStyles(themeStyles)(Main);

View file

@ -24,18 +24,22 @@ import Icon from 'material-ui/Icon';
import Transition from 'react-transition-group/Transition'; import Transition from 'react-transition-group/Transition';
import {Icon as FontAwesome} from 'react-fa'; import {Icon as FontAwesome} from 'react-fa';
import {MENU, DURATION} from './Common'; import {MENU, DURATION} from '../common';
// menuDefault is the default style of the menu. // styles contains the constant styles of the component.
const menuDefault = { const styles = {
menu: {
default: {
transition: `margin-left ${DURATION}ms`, transition: `margin-left ${DURATION}ms`,
}; },
// menuTransition is the additional style of the menu corresponding to the transition's state. transition: {
const menuTransition = {
entered: {marginLeft: -200}, entered: {marginLeft: -200},
},
},
}; };
// Styles for the SideBar component.
const styles = theme => ({ // themeStyles returns the styles generated from the theme for the component.
const themeStyles = theme => ({
list: { list: {
background: theme.palette.background.appBar, background: theme.palette.background.appBar,
}, },
@ -46,38 +50,32 @@ const styles = theme => ({
fontSize: theme.spacing.unit * 3, fontSize: theme.spacing.unit * 3,
}, },
}); });
export type Props = { export type Props = {
classes: Object, classes: Object, // injected by withStyles()
opened: boolean, opened: boolean,
changeContent: () => {}, changeContent: string => void,
}; };
// SideBar renders the sidebar of the dashboard. // SideBar renders the sidebar of the dashboard.
class SideBar extends Component<Props> { class SideBar extends Component<Props> {
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 = {};
MENU.forEach((menu) => {
this.clickOn[menu.id] = (event) => {
event.preventDefault();
props.changeContent(menu.id);
};
});
}
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
return nextProps.opened !== this.props.opened; return nextProps.opened !== this.props.opened;
} }
// clickOn returns a click event handler function for the given menu item.
clickOn = menu => (event) => {
event.preventDefault();
this.props.changeContent(menu);
};
// menuItems returns the menu items corresponding to the sidebar state.
menuItems = (transitionState) => { menuItems = (transitionState) => {
const {classes} = this.props; const {classes} = this.props;
const children = []; const children = [];
MENU.forEach((menu) => { MENU.forEach((menu) => {
children.push( children.push((
<ListItem button key={menu.id} onClick={this.clickOn[menu.id]} className={classes.listItem}> <ListItem button key={menu.id} onClick={this.clickOn(menu.id)} className={classes.listItem}>
<ListItemIcon> <ListItemIcon>
<Icon className={classes.icon}> <Icon className={classes.icon}>
<FontAwesome name={menu.icon} /> <FontAwesome name={menu.icon} />
@ -86,29 +84,25 @@ class SideBar extends Component<Props> {
<ListItemText <ListItemText
primary={menu.title} primary={menu.title}
style={{ style={{
...menuDefault, ...styles.menu.default,
...menuTransition[transitionState], ...styles.menu.transition[transitionState],
padding: 0, padding: 0,
}} }}
/> />
</ListItem>, </ListItem>
); ));
}); });
return children; return children;
}; };
// menu renders the list of the menu items. // menu renders the list of the menu items.
menu = (transitionState) => { menu = (transitionState: Object) => (
const {classes} = this.props; // The classes property is injected by withStyles(). <div className={this.props.classes.list}>
return (
<div className={classes.list}>
<List> <List>
{this.menuItems(transitionState)} {this.menuItems(transitionState)}
</List> </List>
</div> </div>
); );
};
render() { render() {
return ( return (
@ -119,4 +113,4 @@ class SideBar extends Component<Props> {
} }
} }
export default withStyles(styles)(SideBar); export default withStyles(themeStyles)(SideBar);

View file

@ -24,7 +24,7 @@ import createMuiTheme from 'material-ui/styles/createMuiTheme';
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
const theme = createMuiTheme({ const theme: Object = createMuiTheme({
palette: { palette: {
type: 'dark', type: 'dark',
}, },

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{ {
"dependencies": { "dependencies": {
"babel-core": "^6.26.0", "babel-core": "^6.26.0",
"babel-eslint": "^8.1.2", "babel-eslint": "^8.2.1",
"babel-loader": "^7.1.2", "babel-loader": "^7.1.2",
"babel-plugin-transform-class-properties": "^6.24.1", "babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-decorators-legacy": "^1.3.4", "babel-plugin-transform-decorators-legacy": "^1.3.4",
@ -12,8 +12,8 @@
"babel-preset-stage-0": "^6.24.1", "babel-preset-stage-0": "^6.24.1",
"babel-runtime": "^6.26.0", "babel-runtime": "^6.26.0",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"css-loader": "^0.28.8", "css-loader": "^0.28.9",
"eslint": "^4.15.0", "eslint": "^4.16.0",
"eslint-config-airbnb": "^16.1.0", "eslint-config-airbnb": "^16.1.0",
"eslint-loader": "^1.9.0", "eslint-loader": "^1.9.0",
"eslint-plugin-import": "^2.8.0", "eslint-plugin-import": "^2.8.0",
@ -24,16 +24,15 @@
"flow-bin": "^0.63.1", "flow-bin": "^0.63.1",
"flow-bin-loader": "^1.0.2", "flow-bin-loader": "^1.0.2",
"flow-typed": "^2.2.3", "flow-typed": "^2.2.3",
"material-ui": "^1.0.0-beta.24", "material-ui": "^1.0.0-beta.30",
"material-ui-icons": "^1.0.0-beta.17", "material-ui-icons": "^1.0.0-beta.17",
"path": "^0.12.7", "path": "^0.12.7",
"react": "^16.2.0", "react": "^16.2.0",
"react-dom": "^16.2.0", "react-dom": "^16.2.0",
"react-fa": "^5.0.0", "react-fa": "^5.0.0",
"react-transition-group": "^2.2.1", "react-transition-group": "^2.2.1",
"recharts": "^1.0.0-beta.7", "recharts": "^1.0.0-beta.9",
"style-loader": "^0.19.1", "style-loader": "^0.19.1",
"typeface-roboto": "^0.0.50",
"url": "^0.11.0", "url": "^0.11.0",
"url-loader": "^0.6.2", "url-loader": "^0.6.2",
"webpack": "^3.10.0" "webpack": "^3.10.0"

View file

@ -32,8 +32,11 @@ export type General = {
}; };
export type Home = { export type Home = {
memory: ChartEntries, activeMemory: ChartEntries,
traffic: ChartEntries, virtualMemory: ChartEntries,
ingress: ChartEntries,
egress: ChartEntries,
cpu: ChartEntries,
}; };
export type ChartEntries = Array<ChartEntry>; export type ChartEntries = Array<ChartEntry>;

View file

@ -22,7 +22,7 @@ import "time"
var DefaultConfig = Config{ var DefaultConfig = Config{
Host: "localhost", Host: "localhost",
Port: 8080, Port: 8080,
Refresh: 3 * time.Second, Refresh: 5 * time.Second,
} }
// Config contains the configuration parameters of the dashboard. // Config contains the configuration parameters of the dashboard.

View file

@ -29,10 +29,12 @@ import (
"net" "net"
"net/http" "net/http"
"path/filepath" "path/filepath"
"runtime"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/elastic/gosigar"
"github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/p2p" "github.com/ethereum/go-ethereum/p2p"
"github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/params"
@ -42,8 +44,11 @@ import (
) )
const ( const (
memorySampleLimit = 200 // Maximum number of memory data samples activeMemorySampleLimit = 200 // Maximum number of active memory data samples
trafficSampleLimit = 200 // Maximum number of traffic data samples virtualMemorySampleLimit = 200 // Maximum number of virtual memory data samples
ingressSampleLimit = 200 // Maximum number of ingress data samples
egressSampleLimit = 200 // Maximum number of egress data samples
cpuSampleLimit = 200 // Maximum number of cpu data samples
) )
var nextID uint32 // Next connection id var nextID uint32 // Next connection id
@ -76,8 +81,10 @@ func New(config *Config, commit string) (*Dashboard, error) {
config: config, config: config,
quit: make(chan chan error), quit: make(chan chan error),
charts: &HomeMessage{ charts: &HomeMessage{
Memory: ChartEntries{}, ActiveMemory: ChartEntries{},
Traffic: ChartEntries{}, VirtualMemory: ChartEntries{},
Ingress: ChartEntries{},
Egress: ChartEntries{},
}, },
commit: commit, commit: commit,
}, nil }, nil
@ -215,8 +222,11 @@ func (db *Dashboard) apiHandler(conn *websocket.Conn) {
Commit: db.commit, Commit: db.commit,
}, },
Home: &HomeMessage{ Home: &HomeMessage{
Memory: db.charts.Memory, ActiveMemory: db.charts.ActiveMemory,
Traffic: db.charts.Traffic, VirtualMemory: db.charts.VirtualMemory,
Ingress: db.charts.Ingress,
Egress: db.charts.Egress,
CPU: db.charts.CPU,
}, },
} }
// Start tracking the connection and drop at connection loss. // Start tracking the connection and drop at connection loss.
@ -242,38 +252,86 @@ func (db *Dashboard) apiHandler(conn *websocket.Conn) {
func (db *Dashboard) collectData() { func (db *Dashboard) collectData() {
defer db.wg.Done() defer db.wg.Done()
prevIn := metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Count()
prevOut := metrics.DefaultRegistry.Get("p2p/OutboundTraffic").(metrics.Meter).Count()
cpuUsage := gosigar.Cpu{}
cpuUsage.Get()
for { for {
select { select {
case errc := <-db.quit: case errc := <-db.quit:
errc <- nil errc <- nil
return return
case <-time.After(db.config.Refresh): case <-time.After(db.config.Refresh):
inboundTraffic := metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Rate1() var mem runtime.MemStats
memoryInUse := metrics.DefaultRegistry.Get("system/memory/inuse").(metrics.Meter).Rate1() runtime.ReadMemStats(&mem)
curIn := metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Count()
inboundTraffic := float64(curIn - prevIn)
prevIn = curIn
curOut := metrics.DefaultRegistry.Get("p2p/OutboundTraffic").(metrics.Meter).Count()
outboundTraffic := float64(curOut - prevOut)
prevOut = curOut
prevCPUUsage := cpuUsage
cpuUsage.Get()
deltaCPU := cpuUsage.Delta(prevCPUUsage)
now := time.Now() now := time.Now()
memory := &ChartEntry{ activeMemory := &ChartEntry{
Time: now, Time: now,
Value: memoryInUse, Value: float64(mem.Alloc),
} }
traffic := &ChartEntry{ virtualMemory := &ChartEntry{
Time: now,
Value: float64(mem.Sys),
}
ingress := &ChartEntry{
Time: now, Time: now,
Value: inboundTraffic, Value: inboundTraffic,
} }
egress := &ChartEntry{
Time: now,
Value: outboundTraffic,
}
CPU := &ChartEntry{
Time: now,
Value: float64(deltaCPU.User),
}
first := 0 first := 0
if len(db.charts.Memory) == memorySampleLimit { if len(db.charts.ActiveMemory) == activeMemorySampleLimit {
first = 1 first = 1
} }
db.charts.Memory = append(db.charts.Memory[first:], memory) db.charts.ActiveMemory = append(db.charts.ActiveMemory[first:], activeMemory)
first = 0 first = 0
if len(db.charts.Traffic) == trafficSampleLimit { if len(db.charts.VirtualMemory) == virtualMemorySampleLimit {
first = 1 first = 1
} }
db.charts.Traffic = append(db.charts.Traffic[first:], traffic) db.charts.VirtualMemory = append(db.charts.VirtualMemory[first:], virtualMemory)
first = 0
if len(db.charts.Ingress) == ingressSampleLimit {
first = 1
}
db.charts.Ingress = append(db.charts.Ingress[first:], ingress)
first = 0
if len(db.charts.Egress) == egressSampleLimit {
first = 1
}
db.charts.Egress = append(db.charts.Egress[first:], egress)
first = 0
if len(db.charts.CPU) == cpuSampleLimit {
first = 1
}
db.charts.CPU = append(db.charts.CPU[first:], CPU)
db.sendToAll(&Message{ db.sendToAll(&Message{
Home: &HomeMessage{ Home: &HomeMessage{
Memory: ChartEntries{memory}, ActiveMemory: ChartEntries{activeMemory},
Traffic: ChartEntries{traffic}, VirtualMemory: ChartEntries{virtualMemory},
Ingress: ChartEntries{ingress},
Egress: ChartEntries{egress},
CPU: ChartEntries{CPU},
}, },
}) })
} }

View file

@ -34,8 +34,11 @@ type GeneralMessage struct {
} }
type HomeMessage struct { type HomeMessage struct {
Memory ChartEntries `json:"memory,omitempty"` ActiveMemory ChartEntries `json:"activeMemory,omitempty"`
Traffic ChartEntries `json:"traffic,omitempty"` VirtualMemory ChartEntries `json:"virtualMemory,omitempty"`
Ingress ChartEntries `json:"ingress,omitempty"`
Egress ChartEntries `json:"egress,omitempty"`
CPU ChartEntries `json:"cpu,omitempty"`
} }
type ChartEntries []*ChartEntry type ChartEntries []*ChartEntry