dashboard: integrate Flow, sketch message API (#15713)

* dashboard: minor design change

* dashboard: Flow integration, message API

* dashboard: minor polishes, exclude misspell linter
This commit is contained in:
Kurkó Mihály 2017-12-21 17:54:38 +02:00 committed by Péter Szilágyi
parent 52f4d6dd78
commit 9dbb8ef4aa
23 changed files with 49950 additions and 610 deletions

1
.gitignore vendored
View File

@ -35,6 +35,7 @@ profile.cov
.idea .idea
# dashboard # dashboard
/dashboard/assets/flow-typed
/dashboard/assets/node_modules /dashboard/assets/node_modules
/dashboard/assets/stats.json /dashboard/assets/stats.json
/dashboard/assets/public/bundle.js /dashboard/assets/public/bundle.js

View File

@ -9,10 +9,11 @@ The client's UI uses [React][React] with JSX syntax, which is validated by the [
### Development and bundling ### 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: 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) $ (cd dashboard/assets && npm install)
$ (cd dashboard/assets && ./node_modules/.bin/flow-typed 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: 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:
@ -22,13 +23,20 @@ $ (cd dashboard/assets && ./node_modules/.bin/webpack --watch)
$ geth --dashboard --dashboard.assets=dashboard/assets/public --vmodule=dashboard=5 $ geth --dashboard --dashboard.assets=dashboard/assets/public --vmodule=dashboard=5
``` ```
To bundle up the final UI into Geth, run `webpack` and `go generate`: To bundle up the final UI into Geth, run `go generate`:
``` ```
$ (cd dashboard/assets && ./node_modules/.bin/webpack)
$ go generate ./dashboard $ go generate ./dashboard
``` ```
### Static type checking
Since JavaScript doesn't provide type safety, [Flow][Flow] is used to check types. These are only useful during development, so at the end of the process Babel will strip them.
To take advantage of static type checking, your IDE needs to be prepared for it. In case of [Atom][Atom] a configuration guide can be found [here][Atom config]: Install the [Nuclide][Nuclide] package for Flow support, making sure it installs all of its support packages by enabling `Install Recommended Packages on Startup`, and set the path of the `flow-bin` which were installed previously by `npm`.
For more IDE support install the `linter-eslint` package too, which finds the `.eslintrc` file, and provides real-time linting. Atom warns, that these two packages are incompatible, but they seem to work well together. For third-party library errors and auto-completion [flow-typed][flow-typed] is used.
### Have fun ### Have fun
[Webpack][Webpack] offers handy tools for visualizing the bundle's dependency tree and space usage. [Webpack][Webpack] offers handy tools for visualizing the bundle's dependency tree and space usage.
@ -44,3 +52,8 @@ $ go generate ./dashboard
[WA]: http://webpack.github.io/analyse/ [WA]: http://webpack.github.io/analyse/
[WV]: http://chrisbateman.github.io/webpack-visualizer/ [WV]: http://chrisbateman.github.io/webpack-visualizer/
[Node.js]: https://nodejs.org/en/ [Node.js]: https://nodejs.org/en/
[Flow]: https://flow.org/
[Atom]: https://atom.io/
[Atom config]: https://medium.com/@fastphrase/integrating-flow-into-a-react-project-fbbc2f130eed
[Nuclide]: https://nuclide.io/docs/quick-start/getting-started/
[flow-typed]: https://github.com/flowtype/flow-typed

File diff suppressed because one or more lines are too long

View File

@ -16,37 +16,68 @@
// React syntax style mostly according to https://github.com/airbnb/javascript/tree/master/react // React syntax style mostly according to https://github.com/airbnb/javascript/tree/master/react
{ {
"plugins": [ 'env': {
"react" 'browser': true,
], 'node': true,
"parser": "babel-eslint", 'es6': true,
"parserOptions": { },
"ecmaFeatures": { 'parser': 'babel-eslint',
"jsx": true, 'parserOptions': {
"modules": true 'sourceType': 'module',
} 'ecmaVersion': 6,
}, 'ecmaFeatures': {
"rules": { 'jsx': true,
"react/prefer-es6-class": 2, }
"react/prefer-stateless-function": 2, },
"react/jsx-pascal-case": 2, 'extends': 'airbnb',
"react/jsx-closing-bracket-location": [1, {"selfClosing": "tag-aligned", "nonEmpty": "tag-aligned"}], 'plugins': [
"react/jsx-closing-tag-location": 1, 'flowtype',
"jsx-quotes": ["error", "prefer-double"], 'react',
"no-multi-spaces": "error", ],
"react/jsx-tag-spacing": 2, 'rules': {
"react/jsx-curly-spacing": [2, {"when": "never", "children": true}], 'no-tabs': 'off',
"react/jsx-boolean-value": 2, 'indent': ['error', 'tab'],
"react/no-string-refs": 2, 'react/jsx-indent': ['error', 'tab'],
"react/jsx-wrap-multilines": 2, 'react/jsx-indent-props': ['error', 'tab'],
"react/self-closing-comp": 2, 'react/prefer-stateless-function': 'off',
"react/jsx-no-bind": 2,
"react/require-render-return": 2, // Specifies the maximum length of a line.
"react/no-is-mounted": 2, 'max-len': ['warn', 120, 2, {
"key-spacing": ["error", {"align": { 'ignoreUrls': true,
"beforeColon": false, 'ignoreComments': false,
"afterColon": true, 'ignoreRegExpLiterals': true,
"on": "value" 'ignoreStrings': true,
}}] 'ignoreTemplateLiterals': true,
} }],
// Enforces spacing between keys and values in object literal properties.
'key-spacing': ['error', {'align': {
'beforeColon': false,
'afterColon': true,
'on': 'value'
}}],
// Prohibits padding inside curly braces.
'object-curly-spacing': ['error', 'never'],
'no-use-before-define': 'off', // messageAPI
'default-case': 'off',
'flowtype/boolean-style': ['error', 'boolean'],
'flowtype/define-flow-type': 'warn',
'flowtype/generic-spacing': ['error', 'never'],
'flowtype/no-primitive-constructor-types': 'error',
'flowtype/no-weak-types': 'error',
'flowtype/object-type-delimiter': ['error', 'comma'],
'flowtype/require-valid-file-annotation': 'error',
'flowtype/semi': ['error', 'always'],
'flowtype/space-after-type-colon': ['error', 'always'],
'flowtype/space-before-generic-bracket': ['error', 'never'],
'flowtype/space-before-type-colon': ['error', 'never'],
'flowtype/union-intersection-spacing': ['error', 'always'],
'flowtype/use-flow-type': 'warn',
'flowtype/valid-syntax': 'warn',
},
'settings': {
'flowtype': {
'onlyFilesWithFlowAnnotation': true,
}
},
} }

View File

@ -0,0 +1,9 @@
[ignore]
<PROJECT_ROOT>/node_modules/material-ui/.*\.js\.flow
[libs]
<PROJECT_ROOT>/flow-typed/
node_modules/jss/flow-typed
[options]
include_warnings=true

View File

@ -0,0 +1,64 @@
// @flow
// Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import React, {Component} from 'react';
import withStyles from 'material-ui/styles/withStyles';
import SideBar from './SideBar';
import Main from './Main';
import type {Content} from '../types/content';
// Styles for the Body component.
const styles = () => ({
body: {
display: 'flex',
width: '100%',
height: '100%',
},
});
export type Props = {
classes: Object,
opened: boolean,
changeContent: () => {},
active: string,
content: Content,
shouldUpdate: Object,
};
// Body renders the body of the dashboard.
class Body extends Component<Props> {
render() {
const {classes} = this.props; // The classes property is injected by withStyles().
return (
<div className={classes.body}>
<SideBar
opened={this.props.opened}
changeContent={this.props.changeContent}
/>
<Main
active={this.props.active}
content={this.props.content}
shouldUpdate={this.props.shouldUpdate}
/>
</div>
);
}
}
export default withStyles(styles)(Body);

View File

@ -0,0 +1,49 @@
// @flow
// Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import React, {Component} from 'react';
import type {Node} from 'react';
import Grid from 'material-ui/Grid';
import {ResponsiveContainer} from 'recharts';
export type Props = {
spacing: number,
children: Node,
};
// 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<Props> {
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}))})}
</ResponsiveContainer>
</Grid>
))
}
</Grid>
);
}
}
export default ChartGrid;

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors // Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library. // This file is part of the go-ethereum library.
// //
@ -14,39 +16,78 @@
// You should have received a copy of the GNU Lesser General Public License // 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/>. // 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. type ProvidedMenuProp = {|title: string, icon: string|};
export const isNullOrUndefined = variable => variable === null || typeof variable === 'undefined'; const menuSkeletons: Array<{|id: string, menu: ProvidedMenuProp|}> = [
{
export const LIMIT = { id: 'home',
memory: 200, // Maximum number of memory data samples. menu: {
traffic: 200, // Maximum number of traffic data samples. title: 'Home',
log: 200, // Maximum number of logs. icon: 'home',
}; },
}, {
id: 'chain',
menu: {
title: 'Chain',
icon: 'link',
},
}, {
id: 'txpool',
menu: {
title: 'TxPool',
icon: 'credit-card',
},
}, {
id: 'network',
menu: {
title: 'Network',
icon: 'globe',
},
}, {
id: 'system',
menu: {
title: 'System',
icon: 'tachometer',
},
}, {
id: 'logs',
menu: {
title: 'Logs',
icon: 'list',
},
},
];
export type MenuProp = {|...ProvidedMenuProp, id: string|};
// The sidebar menu and the main content are rendered based on these elements. // The sidebar menu and the main content are rendered based on these elements.
export const TAGS = (() => { // Using the id is circumstantial in some cases, so it is better to insert it also as a value.
const T = { // This way the mistyping is prevented.
home: { title: "Home", }, export const MENU: Map<string, {...MenuProp}> = new Map(menuSkeletons.map(({id, menu}) => ([id, {id, ...menu}])));
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 = (() => { type ProvidedSampleProp = {|limit: number|};
const DK = {}; const sampleSkeletons: Array<{|id: string, sample: ProvidedSampleProp|}> = [
["memory", "traffic", "logs"].map(key => { {
DK[key] = key; id: 'memory',
}); sample: {
return DK; limit: 200,
})(); },
}, {
id: 'traffic',
sample: {
limit: 200,
},
}, {
id: 'logs',
sample: {
limit: 200,
},
},
];
export type SampleProp = {|...ProvidedSampleProp, id: string|};
export const SAMPLE: Map<string, {...SampleProp}> = new Map(sampleSkeletons.map(({id, sample}) => ([id, {id, ...sample}])));
// Temporary - taken from Material-UI export const DURATION = 200;
export const DRAWER_WIDTH = 240;
export const LENS: Map<string, string> = new Map([
'content',
...menuSkeletons.map(({id}) => id),
...sampleSkeletons.map(({id}) => id),
].map(lens => [lens, lens]));

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors // Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library. // This file is part of the go-ethereum library.
// //
@ -15,155 +17,183 @@
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>. // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import React, {Component} from 'react'; import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withStyles} from 'material-ui/styles';
import SideBar from './SideBar.jsx'; import withStyles from 'material-ui/styles/withStyles';
import Header from './Header.jsx'; import {lensPath, view, set} from 'ramda';
import Main from "./Main.jsx";
import {isNullOrUndefined, LIMIT, TAGS, DATA_KEYS,} from "./Common.jsx";
// Styles for the Dashboard component. import Header from './Header';
import Body from './Body';
import {MENU, SAMPLE} from './Common';
import type {Message, HomeMessage, LogsMessage, Chart} from '../types/message';
import type {Content} from '../types/content';
// appender appends an array (A) to the end of another array (B) in the state.
// lens is the path of B in the state, samples is A, and limit is the maximum size of the changed array.
//
// appender retrieves a function, which overrides the state's value at lens, and returns with the overridden state.
const appender = (lens, samples, limit) => (state) => {
const newSamples = [
...view(lens, state), // retrieves a specific value of the state at the given path (lens).
...samples,
];
// set is a function of ramda.js, which needs the path, the new value, the original state, and retrieves
// the altered state.
return set(
lens,
newSamples.slice(newSamples.length > limit ? newSamples.length - limit : 0),
state
);
};
// Lenses for specific data fields in the state, used for a clearer deep update.
// NOTE: This solution will be changed very likely.
const memoryLens = lensPath(['content', 'home', 'memory']);
const trafficLens = lensPath(['content', 'home', 'traffic']);
const logLens = lensPath(['content', 'logs', 'log']);
// styles retrieves the styles for the Dashboard component.
const styles = theme => ({ const styles = theme => ({
appFrame: { dashboard: {
position: 'relative', display: 'flex',
display: 'flex', flexFlow: 'column',
width: '100%', width: '100%',
height: '100%', height: '100%',
background: theme.palette.background.default, background: theme.palette.background.default,
}, zIndex: 1,
overflow: 'hidden',
},
}); });
export type Props = {
classes: Object,
};
type State = {
active: string, // active menu
sideBar: boolean, // true if the sidebar is opened
content: $Shape<Content>, // the visualized data
shouldUpdate: Set<string> // labels for the components, which need to rerender based on the incoming message
};
// Dashboard is the main component, which renders the whole page, makes connection with the server and
// listens for messages. When there is an incoming message, updates the page's content correspondingly.
class Dashboard extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
active: MENU.get('home').id,
sideBar: true,
content: {home: {memory: [], traffic: []}, logs: {log: []}},
shouldUpdate: new Set(),
};
}
// Dashboard is the main component, which renders the whole page, makes connection with the server and listens for messages. // componentDidMount initiates the establishment of the first websocket connection after the component is rendered.
// When there is an incoming message, updates the page's content correspondingly. componentDidMount() {
class Dashboard extends Component { this.reconnect();
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. // reconnect establishes a websocket connection with the server, listens for incoming messages
componentDidMount() { // and tries to reconnect on connection loss.
this.reconnect(); reconnect = () => {
} this.setState({
content: {home: {memory: [], traffic: []}, logs: {log: []}},
});
const server = new WebSocket(`${((window.location.protocol === 'https:') ? 'wss://' : 'ws://') + window.location.host}/api`);
server.onmessage = (event) => {
const msg: Message = JSON.parse(event.data);
if (!msg) {
return;
}
this.update(msg);
};
server.onclose = () => {
setTimeout(this.reconnect, 3000);
};
};
// reconnect establishes a websocket connection with the server, listens for incoming messages // samples retrieves the raw data of a chart field from the incoming message.
// and tries to reconnect on connection loss. samples = (chart: Chart) => {
reconnect = () => { let s = [];
const server = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/api"); if (chart.history) {
s = chart.history.map(({value}) => (value || 0)); // traffic comes without value at the beginning
}
if (chart.new) {
s = [...s, chart.new.value || 0];
}
return s;
};
server.onmessage = event => { // handleHome changes the home-menu related part of the state.
const msg = JSON.parse(event.data); handleHome = (home: HomeMessage) => {
if (isNullOrUndefined(msg)) { this.setState((prevState) => {
return; let newState = prevState;
} newState.shouldUpdate = new Set();
this.update(msg); if (home.memory) {
}; newState = appender(memoryLens, this.samples(home.memory), SAMPLE.get('memory').limit)(newState);
newState.shouldUpdate.add('memory');
}
if (home.traffic) {
newState = appender(trafficLens, this.samples(home.traffic), SAMPLE.get('traffic').limit)(newState);
newState.shouldUpdate.add('traffic');
}
return newState;
});
};
server.onclose = () => { // handleLogs changes the logs-menu related part of the state.
setTimeout(this.reconnect, 3000); handleLogs = (logs: LogsMessage) => {
}; this.setState((prevState) => {
}; let newState = prevState;
newState.shouldUpdate = new Set();
if (logs.log) {
newState = appender(logLens, [logs.log], SAMPLE.get('logs').limit)(newState);
newState.shouldUpdate.add('logs');
}
return newState;
});
};
// update analyzes the incoming message, and updates the charts' content correspondingly. // update analyzes the incoming message, and updates the charts' content correspondingly.
update = msg => { update = (msg: Message) => {
console.log(msg); if (msg.home) {
this.setState(prevState => { this.handleHome(msg.home);
let newState = []; }
newState.shouldUpdate = {}; if (msg.logs) {
const insert = (key, values, limit) => { this.handleLogs(msg.logs);
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; // changeContent sets the active label, which is used at the content rendering.
}); changeContent = (newActive: string) => {
}; this.setState(prevState => (prevState.active !== newActive ? {active: newActive} : {}));
};
// The change of the active label on the SideBar component will trigger a new render in the Main component. // openSideBar opens the sidebar.
changeContent = active => { openSideBar = () => {
this.setState(prevState => prevState.active !== active ? {active: active} : {}); this.setState({sideBar: true});
}; };
openSideBar = () => { // closeSideBar closes the sidebar.
this.setState({sideBar: true}); closeSideBar = () => {
}; this.setState({sideBar: false});
};
closeSideBar = () => { render() {
this.setState({sideBar: false}); const {classes} = this.props; // The classes property is injected by withStyles().
};
render() { return (
// The classes property is injected by withStyles(). <div className={classes.dashboard}>
const {classes} = this.props; <Header
opened={this.state.sideBar}
return ( openSideBar={this.openSideBar}
<div className={classes.appFrame}> closeSideBar={this.closeSideBar}
<Header />
opened={this.state.sideBar} <Body
open={this.openSideBar} opened={this.state.sideBar}
/> changeContent={this.changeContent}
<SideBar active={this.state.active}
opened={this.state.sideBar} content={this.state.content}
close={this.closeSideBar} shouldUpdate={this.state.shouldUpdate}
changeContent={this.changeContent} />
/> </div>
<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); export default withStyles(styles)(Dashboard);

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors // Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library. // This file is part of the go-ethereum library.
// //
@ -15,73 +17,89 @@
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>. // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import React, {Component} from 'react'; import React, {Component} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import withStyles from 'material-ui/styles/withStyles';
import {withStyles} from 'material-ui/styles';
import AppBar from 'material-ui/AppBar'; import AppBar from 'material-ui/AppBar';
import Toolbar from 'material-ui/Toolbar'; import Toolbar from 'material-ui/Toolbar';
import Typography from 'material-ui/Typography'; import Transition from 'react-transition-group/Transition';
import IconButton from 'material-ui/IconButton'; import IconButton from 'material-ui/IconButton';
import MenuIcon from 'material-ui-icons/Menu'; import Typography from 'material-ui/Typography';
import ChevronLeftIcon from 'material-ui-icons/ChevronLeft';
import {DRAWER_WIDTH} from './Common.jsx'; import {DURATION} from './Common';
// arrowDefault is the default style of the arrow button.
const arrowDefault = {
transition: `transform ${DURATION}ms`,
};
// arrowTransition is the additional style of the arrow button corresponding to the transition's state.
const arrowTransition = {
entered: {transform: 'rotate(180deg)'},
};
// Styles for the Header component. // Styles for the Header component.
const styles = theme => ({ const styles = theme => ({
appBar: { header: {
position: 'absolute', backgroundColor: theme.palette.background.appBar,
transition: theme.transitions.create(['margin', 'width'], { color: theme.palette.getContrastText(theme.palette.background.appBar),
easing: theme.transitions.easing.sharp, zIndex: theme.zIndex.appBar,
duration: theme.transitions.duration.leavingScreen, },
}), toolbar: {
}, paddingLeft: theme.spacing.unit,
appBarShift: { paddingRight: theme.spacing.unit,
marginLeft: DRAWER_WIDTH, },
width: `calc(100% - ${DRAWER_WIDTH}px)`, mainText: {
transition: theme.transitions.create(['margin', 'width'], { paddingLeft: theme.spacing.unit,
easing: theme.transitions.easing.easeOut, },
duration: theme.transitions.duration.enteringScreen,
}),
},
menuButton: {
marginLeft: 12,
marginRight: 20,
},
hide: {
display: 'none',
},
}); });
export type Props = {
classes: Object,
opened: boolean,
openSideBar: () => {},
closeSideBar: () => {},
};
// Header renders the header of the dashboard.
class Header extends Component<Props> {
shouldComponentUpdate(nextProps) {
return nextProps.opened !== this.props.opened;
}
// Header renders a header, which contains a sidebar opener icon when that is closed. // changeSideBar opens or closes the sidebar corresponding to the previous state.
class Header extends Component { changeSideBar = () => {
render() { if (this.props.opened) {
// The classes property is injected by withStyles(). this.props.closeSideBar();
const {classes} = this.props; } else {
this.props.openSideBar();
}
};
return ( // arrowButton is connected to the sidebar; changes its state.
<AppBar className={classNames(classes.appBar, this.props.opened && classes.appBarShift)}> arrowButton = (transitionState: string) => (
<Toolbar disableGutters={!this.props.opened}> <IconButton onClick={this.changeSideBar}>
<IconButton <ChevronLeftIcon
color="contrast" style={{
aria-label="open drawer" ...arrowDefault,
onClick={this.props.open} ...arrowTransition[transitionState],
className={classNames(classes.menuButton, this.props.opened && classes.hide)} }}
> />
<MenuIcon /> </IconButton>
</IconButton> );
<Typography type="title" color="inherit" noWrap>
Go Ethereum Dashboard render() {
</Typography> const {classes, opened} = this.props; // The classes property is injected by withStyles().
</Toolbar>
</AppBar> return (
); <AppBar position="static" className={classes.header}>
} <Toolbar className={classes.toolbar}>
<Transition mountOnEnter in={opened} timeout={{enter: DURATION}}>
{this.arrowButton}
</Transition>
<Typography type="title" color="inherit" noWrap className={classes.mainText}>
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); export default withStyles(styles)(Header);

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors // Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library. // This file is part of the go-ethereum library.
// //
@ -15,75 +17,56 @@
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>. // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import React, {Component} from 'react'; import React, {Component} from 'react';
import PropTypes from 'prop-types';
import 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"; import withTheme from 'material-ui/styles/withTheme';
import {LineChart, AreaChart, Area, YAxis, CartesianGrid, Line} from 'recharts';
// ChartGrid renders a grid container for responsive charts. import ChartGrid from './ChartGrid';
// The children are Recharts components extended with the Material-UI's xs property. import type {ChartEntry} from '../types/message';
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 = { export type Props = {
spacing: PropTypes.number.isRequired, theme: Object,
memory: Array<ChartEntry>,
traffic: Array<ChartEntry>,
shouldUpdate: Object,
}; };
// Home renders the home content.
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];
}
// Home renders the home component. shouldComponentUpdate(nextProps) {
class Home extends Component { return nextProps.shouldUpdate.has('memory') || nextProps.shouldUpdate.has('traffic');
shouldComponentUpdate(nextProps) { }
return !isNullOrUndefined(nextProps.shouldUpdate[DATA_KEYS.memory]) ||
!isNullOrUndefined(nextProps.shouldUpdate[DATA_KEYS.traffic]);
}
render() { render() {
const {theme} = this.props; const {memory, traffic} = this.props;
const memoryColor = theme.palette.primary[300];
const trafficColor = theme.palette.secondary[300];
return ( return (
<ChartGrid spacing={24}> <ChartGrid spacing={24}>
<AreaChart xs={6} height={300} values={this.props.memory}> <AreaChart xs={6} height={300} values={memory}>
<YAxis /> <YAxis />
<Area type="monotone" dataKey="value" stroke={memoryColor} fill={memoryColor} /> <Area type="monotone" dataKey="value" stroke={this.memoryColor} fill={this.memoryColor} />
</AreaChart> </AreaChart>
<LineChart xs={6} height={300} values={this.props.traffic}> <LineChart xs={6} height={300} values={traffic}>
<Line type="monotone" dataKey="value" stroke={trafficColor} dot={false} /> <Line type="monotone" dataKey="value" stroke={this.trafficColor} dot={false} />
</LineChart> </LineChart>
<LineChart xs={6} height={300} values={this.props.memory}> <LineChart xs={6} height={300} values={memory}>
<YAxis /> <YAxis />
<CartesianGrid stroke="#eee" strokeDasharray="5 5" /> <CartesianGrid stroke="#eee" strokeDasharray="5 5" />
<Line type="monotone" dataKey="value" stroke={memoryColor} dot={false} /> <Line type="monotone" dataKey="value" stroke={this.memoryColor} dot={false} />
</LineChart> </LineChart>
<AreaChart xs={6} height={300} values={this.props.traffic}> <AreaChart xs={6} height={300} values={traffic}>
<CartesianGrid stroke="#eee" strokeDasharray="5 5" vertical={false} /> <CartesianGrid stroke="#eee" strokeDasharray="5 5" vertical={false} />
<Area type="monotone" dataKey="value" stroke={trafficColor} fill={trafficColor} /> <Area type="monotone" dataKey="value" stroke={this.trafficColor} fill={this.trafficColor} />
</AreaChart> </AreaChart>
</ChartGrid> </ChartGrid>
); );
} }
} }
Home.propTypes = {
theme: PropTypes.object.isRequired,
shouldUpdate: PropTypes.object.isRequired,
};
export default withTheme()(Home); export default withTheme()(Home);

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors // Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library. // This file is part of the go-ethereum library.
// //
@ -15,95 +17,52 @@
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>. // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import React, {Component} from 'react'; import React, {Component} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {withStyles} from 'material-ui/styles';
import {TAGS, DRAWER_WIDTH} from "./Common.jsx"; import withStyles from 'material-ui/styles/withStyles';
import Home from './Home.jsx';
// ContentSwitch chooses and renders the proper page content. import Home from './Home';
class ContentSwitch extends Component { import {MENU} from './Common';
render() { import type {Content} from '../types/content';
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 = { // Styles for the Content component.
active: PropTypes.string.isRequired,
shouldUpdate: PropTypes.object.isRequired,
};
// styles contains the styles for the Main component.
const styles = theme => ({ const styles = theme => ({
content: { content: {
width: '100%', flexGrow: 1,
marginLeft: -DRAWER_WIDTH, backgroundColor: theme.palette.background.default,
flexGrow: 1, padding: theme.spacing.unit * 3,
backgroundColor: theme.palette.background.default, overflow: 'auto',
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,
}),
},
}); });
export type Props = {
// Main renders a component for the page content. classes: Object,
class Main extends Component { active: string,
render() { content: Content,
// The classes property is injected by withStyles(). shouldUpdate: Object,
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,
}; };
// Main renders the chosen content.
class Main extends Component<Props> {
render() {
const {
classes, active, content, shouldUpdate,
} = this.props;
let children = null;
switch (active) {
case MENU.get('home').id:
children = <Home memory={content.home.memory} traffic={content.home.traffic} shouldUpdate={shouldUpdate} />;
break;
case MENU.get('chain').id:
case MENU.get('txpool').id:
case MENU.get('network').id:
case MENU.get('system').id:
children = <div>Work in progress.</div>;
break;
case MENU.get('logs').id:
children = <div>{content.logs.log.map((log, index) => <div key={index}>{log}</div>)}</div>;
}
return <div className={classes.content}>{children}</div>;
}
}
export default withStyles(styles)(Main); export default withStyles(styles)(Main);

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors // Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library. // This file is part of the go-ethereum library.
// //
@ -15,92 +17,106 @@
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>. // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import React, {Component} from 'react'; import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {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'; import withStyles from 'material-ui/styles/withStyles';
import List, {ListItem, ListItemIcon, ListItemText} from 'material-ui/List';
import Icon from 'material-ui/Icon';
import Transition from 'react-transition-group/Transition';
import {Icon as FontAwesome} from 'react-fa';
import {MENU, DURATION} from './Common';
// menuDefault is the default style of the menu.
const menuDefault = {
transition: `margin-left ${DURATION}ms`,
};
// menuTransition is the additional style of the menu corresponding to the transition's state.
const menuTransition = {
entered: {marginLeft: -200},
};
// Styles for the SideBar component. // Styles for the SideBar component.
const styles = theme => ({ const styles = theme => ({
drawerPaper: { list: {
position: 'relative', background: theme.palette.background.appBar,
height: '100%', },
width: DRAWER_WIDTH, listItem: {
}, minWidth: theme.spacing.unit * 3,
drawerHeader: { },
display: 'flex', icon: {
alignItems: 'center', fontSize: theme.spacing.unit * 3,
justifyContent: 'flex-end', },
padding: '0 8px',
...theme.mixins.toolbar,
transitionDuration: {
enter: theme.transitions.duration.enteringScreen,
exit: theme.transitions.duration.leavingScreen,
}
},
}); });
export type Props = {
classes: Object,
opened: boolean,
changeContent: () => {},
};
// SideBar renders the sidebar of the dashboard.
class SideBar extends Component<Props> {
constructor(props) {
super(props);
// SideBar renders a sidebar component. // clickOn contains onClick event functions for the menu items.
class SideBar extends Component { // Instantiate only once, and reuse the existing functions to prevent the creation of
constructor(props) { // new function instances every time the render method is triggered.
super(props); this.clickOn = {};
MENU.forEach((menu) => {
this.clickOn[menu.id] = (event) => {
event.preventDefault();
props.changeContent(menu.id);
};
});
}
// clickOn contains onClick event functions for the menu items. shouldComponentUpdate(nextProps) {
// Instantiate only once, and reuse the existing functions to prevent the creation of return nextProps.opened !== this.props.opened;
// 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() { menuItems = (transitionState) => {
// The classes property is injected by withStyles(). const {classes} = this.props;
const {classes} = this.props; const children = [];
MENU.forEach((menu) => {
children.push(
<ListItem button key={menu.id} onClick={this.clickOn[menu.id]} className={classes.listItem}>
<ListItemIcon>
<Icon className={classes.icon}>
<FontAwesome name={menu.icon} />
</Icon>
</ListItemIcon>
<ListItemText
primary={menu.title}
style={{
...menuDefault,
...menuTransition[transitionState],
padding: 0,
}}
/>
</ListItem>,
);
});
return children;
};
return ( // menu renders the list of the menu items.
<Drawer menu = (transitionState) => {
type="persistent" const {classes} = this.props; // The classes property is injected by withStyles().
classes={{paper: classes.drawerPaper,}}
open={this.props.opened} return (
> <div className={classes.list}>
<div> <List>
<div className={classes.drawerHeader}> {this.menuItems(transitionState)}
<IconButton onClick={this.props.close}> </List>
<ChevronLeftIcon /> </div>
</IconButton> );
</div> };
<List>
{ render() {
Object.values(TAGS).map(tag => { return (
return ( <Transition mountOnEnter in={this.props.opened} timeout={{enter: DURATION}}>
<ListItem button key={tag.id} onClick={this.clickOn[tag.id]}> {this.menu}
<ListItemText primary={tag.title} /> </Transition>
</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); export default withStyles(styles)(SideBar);

View File

@ -0,0 +1,25 @@
// Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
// fa-only-woff-loader removes the .eot, .ttf, .svg dependencies of the FontAwesome library,
// because they produce unused extra blobs.
module.exports = function(content) {
return content
.replace(/src.*url(?!.*url.*(\.eot)).*(\.eot)[^;]*;/,'')
.replace(/url(?!.*url.*(\.eot)).*(\.eot)[^,]*,/,'')
.replace(/url(?!.*url.*(\.ttf)).*(\.ttf)[^,]*,/,'')
.replace(/,[^,]*url(?!.*url.*(\.svg)).*(\.svg)[^;]*;/,';');
};

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors // Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library. // This file is part of the go-ethereum library.
// //
@ -15,22 +17,25 @@
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>. // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import React from 'react'; import React from 'react';
import {hydrate} from 'react-dom'; import {render} from 'react-dom';
import {createMuiTheme, MuiThemeProvider} from 'material-ui/styles';
import Dashboard from './components/Dashboard.jsx'; import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
import createMuiTheme from 'material-ui/styles/createMuiTheme';
import Dashboard from './components/Dashboard';
// Theme for the dashboard.
const theme = createMuiTheme({ const theme = createMuiTheme({
palette: { palette: {
type: 'dark', type: 'dark',
}, },
}); });
const dashboard = document.getElementById('dashboard');
// Renders the whole dashboard. if (dashboard) {
hydrate( // Renders the whole dashboard.
<MuiThemeProvider theme={theme}> render(
<Dashboard /> <MuiThemeProvider theme={theme}>
</MuiThemeProvider>, <Dashboard />
document.getElementById('dashboard') </MuiThemeProvider>,
); dashboard,
);
}

6806
dashboard/assets/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,41 @@
{ {
"dependencies": { "dependencies": {
"babel-core": "^6.26.0", "babel-core": "^6.26.0",
"babel-eslint": "^8.0.1", "babel-eslint": "^8.0.3",
"babel-loader": "^7.1.2", "babel-loader": "^7.1.2",
"babel-preset-env": "^1.6.1", "babel-plugin-transform-class-properties": "^6.24.1",
"babel-preset-react": "^6.24.1", "babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-preset-stage-0": "^6.24.1", "babel-plugin-transform-flow-strip-types": "^6.22.0",
"classnames": "^2.2.5", "babel-plugin-transform-runtime": "^6.23.0",
"eslint": "^4.5.0", "babel-preset-env": "^1.6.1",
"eslint-plugin-react": "^7.4.0", "babel-preset-react": "^6.24.1",
"material-ui": "^1.0.0-beta.18", "babel-preset-stage-0": "^6.24.1",
"material-ui-icons": "^1.0.0-beta.17", "babel-runtime": "^6.26.0",
"path": "^0.12.7", "classnames": "^2.2.5",
"prop-types": "^15.6.0", "css-loader": "^0.28.7",
"recharts": "^1.0.0-beta.0", "eslint": "^4.13.1",
"react": "^16.0.0", "eslint-config-airbnb": "^16.1.0",
"react-dom": "^16.0.0", "eslint-loader": "^1.9.0",
"url": "^0.11.0", "eslint-plugin-import": "^2.8.0",
"webpack": "^3.5.5" "eslint-plugin-jsx-a11y": "^6.0.3",
} "eslint-plugin-react": "^7.5.1",
"eslint-plugin-flowtype": "^2.40.1",
"file-loader": "^1.1.6",
"flow-bin": "^0.61.0",
"flow-bin-loader": "^1.0.2",
"flow-typed": "^2.2.3",
"material-ui": "^1.0.0-beta.24",
"material-ui-icons": "^1.0.0-beta.17",
"path": "^0.12.7",
"ramda": "^0.25.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-fa": "^5.0.0",
"react-transition-group": "^2.2.1",
"recharts": "^1.0.0-beta.6",
"style-loader": "^0.19.1",
"url": "^0.11.0",
"url-loader": "^0.6.2",
"webpack": "^3.10.0"
}
} }

View File

@ -6,9 +6,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Go Ethereum Dashboard</title> <title>Go Ethereum Dashboard</title>
<link rel="shortcut icon" type="image/ico" href="https://ethereum.org/favicon.ico"/> <link rel="shortcut icon" type="image/ico" href="https://ethereum.org/favicon.ico" />
<style>
<!-- TODO (kurkomisi): Return to the external libraries to speed up the bundling during development --> ::-webkit-scrollbar {
width: 16px;
}
::-webkit-scrollbar-thumb {
background: #212121;
}
</style>
</head> </head>
<body style="height: 100%; margin: 0"> <body style="height: 100%; margin: 0">
<div id="dashboard" style="height: 100%"></div> <div id="dashboard" style="height: 100%"></div>

View File

@ -0,0 +1,53 @@
// @flow
// Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
import type {ChartEntry} from './message';
export type Content = {
home: Home,
chain: Chain,
txpool: TxPool,
network: Network,
system: System,
logs: Logs,
};
export type Home = {
memory: Array<ChartEntry>,
traffic: Array<ChartEntry>,
};
export type Chain = {
/* TODO (kurkomisi) */
};
export type TxPool = {
/* TODO (kurkomisi) */
};
export type Network = {
/* TODO (kurkomisi) */
};
export type System = {
/* TODO (kurkomisi) */
};
export type Logs = {
log: Array<string>,
};

View File

@ -0,0 +1,61 @@
// @flow
// Copyright 2017 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// The go-ethereum library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
export type Message = {
home?: HomeMessage,
chain?: ChainMessage,
txpool?: TxPoolMessage,
network?: NetworkMessage,
system?: SystemMessage,
logs?: LogsMessage,
};
export type HomeMessage = {
memory?: Chart,
traffic?: Chart,
};
export type Chart = {
history?: Array<ChartEntry>,
new?: ChartEntry,
};
export type ChartEntry = {
time: Date,
value: number,
};
export type ChainMessage = {
/* TODO (kurkomisi) */
};
export type TxPoolMessage = {
/* TODO (kurkomisi) */
};
export type NetworkMessage = {
/* TODO (kurkomisi) */
};
export type SystemMessage = {
/* TODO (kurkomisi) */
};
export type LogsMessage = {
log: string,
};

View File

@ -14,23 +14,61 @@
// You should have received a copy of the GNU Lesser General Public License // 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/>. // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
const webpack = require('webpack');
const path = require('path'); const path = require('path');
module.exports = { module.exports = {
entry: './index.jsx', resolve: {
output: { extensions: ['.js', '.jsx'],
path: path.resolve(__dirname, 'public'), },
filename: 'bundle.js', entry: './index',
}, output: {
module: { path: path.resolve(__dirname, 'public'),
loaders: [ filename: 'bundle.js',
{ },
test: /\.jsx$/, // regexp for JSX files plugins: [
loader: 'babel-loader', // The babel configuration is in the package.json. new webpack.optimize.UglifyJsPlugin({
query: { comments: false,
presets: ['env', 'react', 'stage-0'] mangle: false,
} beautify: true,
}, }),
], ],
}, module: {
rules: [
{
test: /\.jsx$/, // regexp for JSX files
exclude: /node_modules/,
use: [ // order: from bottom to top
{
loader: 'babel-loader',
options: {
plugins: [ // order: from top to bottom
// 'transform-decorators-legacy', // @withStyles, @withTheme
'transform-class-properties', // static defaultProps
'transform-flow-strip-types',
],
presets: [ // order: from bottom to top
'env',
'react',
'stage-0',
],
},
},
// 'eslint-loader', // show errors not only in the editor, but also in the console
],
},
{
test: /font-awesome\.css$/,
use: [
'style-loader',
'css-loader',
path.resolve(__dirname, './fa-only-woff-loader.js'),
],
},
{
test: /\.woff2?$/, // font-awesome icons
use: 'url-loader',
},
],
},
}; };

View File

@ -16,7 +16,10 @@
package dashboard package dashboard
//go:generate go-bindata -nometadata -o assets.go -prefix assets -pkg dashboard assets/public/... //go:generate ./assets/node_modules/.bin/webpack --config ./assets/webpack.config.js --context ./assets
//go:generate go-bindata -nometadata -o assets.go -prefix assets -nocompress -pkg dashboard assets/public/...
//go:generate gofmt -s -w assets.go
//go:generate sed -i "s#var _public#//nolint:misspell\\n&#" assets.go
import ( import (
"fmt" "fmt"
@ -40,7 +43,7 @@ const (
trafficSampleLimit = 200 // Maximum number of traffic data samples trafficSampleLimit = 200 // Maximum number of traffic data samples
) )
var nextId uint32 // Next connection id var nextID uint32 // Next connection id
// Dashboard contains the dashboard internals. // Dashboard contains the dashboard internals.
type Dashboard struct { type Dashboard struct {
@ -48,46 +51,30 @@ type Dashboard struct {
listener net.Listener listener net.Listener
conns map[uint32]*client // Currently live websocket connections conns map[uint32]*client // Currently live websocket connections
charts charts // The collected data samples to plot charts *HomeMessage
lock sync.RWMutex // Lock protecting the dashboard's internals lock sync.RWMutex // Lock protecting the dashboard's internals
quit chan chan error // Channel used for graceful exit quit chan chan error // Channel used for graceful exit
wg sync.WaitGroup wg sync.WaitGroup
} }
// message embraces the data samples of a client message.
type message struct {
History *charts `json:"history,omitempty"` // Past data samples
Memory *chartEntry `json:"memory,omitempty"` // One memory sample
Traffic *chartEntry `json:"traffic,omitempty"` // One traffic sample
Log string `json:"log,omitempty"` // One log
}
// client represents active websocket connection with a remote browser. // client represents active websocket connection with a remote browser.
type client struct { type client struct {
conn *websocket.Conn // Particular live websocket connection conn *websocket.Conn // Particular live websocket connection
msg chan message // Message queue for the update messages msg chan Message // Message queue for the update messages
logger log.Logger // Logger for the particular live websocket connection logger log.Logger // Logger for the particular live websocket connection
} }
// 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. // New creates a new dashboard instance with the given configuration.
func New(config *Config) (*Dashboard, error) { func New(config *Config) (*Dashboard, error) {
return &Dashboard{ return &Dashboard{
conns: make(map[uint32]*client), conns: make(map[uint32]*client),
config: config, config: config,
quit: make(chan chan error), quit: make(chan chan error),
charts: &HomeMessage{
Memory: &Chart{},
Traffic: &Chart{},
},
}, nil }, nil
} }
@ -183,13 +170,13 @@ func (db *Dashboard) webHandler(w http.ResponseWriter, r *http.Request) {
// apiHandler handles requests for the dashboard. // apiHandler handles requests for the dashboard.
func (db *Dashboard) apiHandler(conn *websocket.Conn) { func (db *Dashboard) apiHandler(conn *websocket.Conn) {
id := atomic.AddUint32(&nextId, 1) id := atomic.AddUint32(&nextID, 1)
client := &client{ client := &client{
conn: conn, conn: conn,
msg: make(chan message, 128), msg: make(chan Message, 128),
logger: log.New("id", id), logger: log.New("id", id),
} }
done := make(chan struct{}) // Buffered channel as sender may exit early done := make(chan struct{})
// Start listening for messages to send. // Start listening for messages to send.
db.wg.Add(1) db.wg.Add(1)
@ -210,8 +197,15 @@ func (db *Dashboard) apiHandler(conn *websocket.Conn) {
} }
}() }()
// Send the past data. // Send the past data.
client.msg <- message{ client.msg <- Message{
History: &db.charts, Home: &HomeMessage{
Memory: &Chart{
History: db.charts.Memory.History,
},
Traffic: &Chart{
History: db.charts.Traffic.History,
},
},
} }
// Start tracking the connection and drop at connection loss. // Start tracking the connection and drop at connection loss.
db.lock.Lock() db.lock.Lock()
@ -245,29 +239,34 @@ func (db *Dashboard) collectData() {
inboundTraffic := metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Rate1() inboundTraffic := metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Rate1()
memoryInUse := metrics.DefaultRegistry.Get("system/memory/inuse").(metrics.Meter).Rate1() memoryInUse := metrics.DefaultRegistry.Get("system/memory/inuse").(metrics.Meter).Rate1()
now := time.Now() now := time.Now()
memory := &chartEntry{ memory := &ChartEntry{
Time: now, Time: now,
Value: memoryInUse, Value: memoryInUse,
} }
traffic := &chartEntry{ traffic := &ChartEntry{
Time: now, Time: now,
Value: inboundTraffic, Value: inboundTraffic,
} }
// Remove the first elements in case the samples' amount exceeds the limit.
first := 0 first := 0
if len(db.charts.Memory) == memorySampleLimit { if len(db.charts.Memory.History) == memorySampleLimit {
first = 1 first = 1
} }
db.charts.Memory = append(db.charts.Memory[first:], memory) db.charts.Memory.History = append(db.charts.Memory.History[first:], memory)
first = 0 first = 0
if len(db.charts.Traffic) == trafficSampleLimit { if len(db.charts.Traffic.History) == trafficSampleLimit {
first = 1 first = 1
} }
db.charts.Traffic = append(db.charts.Traffic[first:], traffic) db.charts.Traffic.History = append(db.charts.Traffic.History[first:], traffic)
db.sendToAll(&message{ db.sendToAll(&Message{
Memory: memory, Home: &HomeMessage{
Traffic: traffic, Memory: &Chart{
New: memory,
},
Traffic: &Chart{
New: traffic,
},
},
}) })
} }
} }
@ -277,6 +276,7 @@ func (db *Dashboard) collectData() {
func (db *Dashboard) collectLogs() { func (db *Dashboard) collectLogs() {
defer db.wg.Done() defer db.wg.Done()
id := 1
// TODO (kurkomisi): log collection comes here. // TODO (kurkomisi): log collection comes here.
for { for {
select { select {
@ -284,15 +284,18 @@ func (db *Dashboard) collectLogs() {
errc <- nil errc <- nil
return return
case <-time.After(db.config.Refresh / 2): case <-time.After(db.config.Refresh / 2):
db.sendToAll(&message{ db.sendToAll(&Message{
Log: "This is a fake log.", Logs: &LogsMessage{
Log: fmt.Sprintf("%-4d: This is a fake log.", id),
},
}) })
id++
} }
} }
} }
// sendToAll sends the given message to the active dashboards. // sendToAll sends the given message to the active dashboards.
func (db *Dashboard) sendToAll(msg *message) { func (db *Dashboard) sendToAll(msg *Message) {
db.lock.Lock() db.lock.Lock()
for _, c := range db.conns { for _, c := range db.conns {
select { select {

63
dashboard/message.go Normal file
View File

@ -0,0 +1,63 @@
// 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"
type Message struct {
Home *HomeMessage `json:"home,omitempty"`
Chain *ChainMessage `json:"chain,omitempty"`
TxPool *TxPoolMessage `json:"txpool,omitempty"`
Network *NetworkMessage `json:"network,omitempty"`
System *SystemMessage `json:"system,omitempty"`
Logs *LogsMessage `json:"logs,omitempty"`
}
type HomeMessage struct {
Memory *Chart `json:"memory,omitempty"`
Traffic *Chart `json:"traffic,omitempty"`
}
type Chart struct {
History []*ChartEntry `json:"history,omitempty"`
New *ChartEntry `json:"new,omitempty"`
}
type ChartEntry struct {
Time time.Time `json:"time,omitempty"`
Value float64 `json:"value,omitempty"`
}
type ChainMessage struct {
/* TODO (kurkomisi) */
}
type TxPoolMessage struct {
/* TODO (kurkomisi) */
}
type NetworkMessage struct {
/* TODO (kurkomisi) */
}
type SystemMessage struct {
/* TODO (kurkomisi) */
}
type LogsMessage struct {
Log string `json:"log,omitempty"`
}