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
# dashboard
/dashboard/assets/flow-typed
/dashboard/assets/node_modules
/dashboard/assets/stats.json
/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
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 && ./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:
@ -22,13 +23,20 @@ $ (cd dashboard/assets && ./node_modules/.bin/webpack --watch)
$ geth --dashboard --dashboard.assets=dashboard/assets/public --vmodule=dashboard=5
```
To bundle up the final UI into Geth, run `webpack` and `go generate`:
To bundle up the final UI into Geth, run `go generate`:
```
$ (cd dashboard/assets && ./node_modules/.bin/webpack)
$ 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
[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/
[WV]: http://chrisbateman.github.io/webpack-visualizer/
[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
{
"plugins": [
"react"
],
"parser": "babel-eslint",
"parserOptions": {
"ecmaFeatures": {
"jsx": true,
"modules": true
}
},
"rules": {
"react/prefer-es6-class": 2,
"react/prefer-stateless-function": 2,
"react/jsx-pascal-case": 2,
"react/jsx-closing-bracket-location": [1, {"selfClosing": "tag-aligned", "nonEmpty": "tag-aligned"}],
"react/jsx-closing-tag-location": 1,
"jsx-quotes": ["error", "prefer-double"],
"no-multi-spaces": "error",
"react/jsx-tag-spacing": 2,
"react/jsx-curly-spacing": [2, {"when": "never", "children": true}],
"react/jsx-boolean-value": 2,
"react/no-string-refs": 2,
"react/jsx-wrap-multilines": 2,
"react/self-closing-comp": 2,
"react/jsx-no-bind": 2,
"react/require-render-return": 2,
"react/no-is-mounted": 2,
"key-spacing": ["error", {"align": {
"beforeColon": false,
"afterColon": true,
"on": "value"
}}]
}
'env': {
'browser': true,
'node': true,
'es6': true,
},
'parser': 'babel-eslint',
'parserOptions': {
'sourceType': 'module',
'ecmaVersion': 6,
'ecmaFeatures': {
'jsx': true,
}
},
'extends': 'airbnb',
'plugins': [
'flowtype',
'react',
],
'rules': {
'no-tabs': 'off',
'indent': ['error', 'tab'],
'react/jsx-indent': ['error', 'tab'],
'react/jsx-indent-props': ['error', 'tab'],
'react/prefer-stateless-function': 'off',
// Specifies the maximum length of a line.
'max-len': ['warn', 120, 2, {
'ignoreUrls': true,
'ignoreComments': false,
'ignoreRegExpLiterals': true,
'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
// 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
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
// isNullOrUndefined returns true if the given variable is null or undefined.
export const isNullOrUndefined = variable => variable === null || typeof variable === 'undefined';
export const LIMIT = {
memory: 200, // Maximum number of memory data samples.
traffic: 200, // Maximum number of traffic data samples.
log: 200, // Maximum number of logs.
};
type ProvidedMenuProp = {|title: string, icon: string|};
const menuSkeletons: Array<{|id: string, menu: ProvidedMenuProp|}> = [
{
id: 'home',
menu: {
title: 'Home',
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.
export const TAGS = (() => {
const T = {
home: { title: "Home", },
chain: { title: "Chain", },
transactions: { title: "Transactions", },
network: { title: "Network", },
system: { title: "System", },
logs: { title: "Logs", },
};
// Using the key is circumstantial in some cases, so it is better to insert it also as a value.
// This way the mistyping is prevented.
for(let key in T) {
T[key]['id'] = key;
}
return T;
})();
// Using the id is circumstantial in some cases, so it is better to insert it also as a value.
// This way the mistyping is prevented.
export const MENU: Map<string, {...MenuProp}> = new Map(menuSkeletons.map(({id, menu}) => ([id, {id, ...menu}])));
export const DATA_KEYS = (() => {
const DK = {};
["memory", "traffic", "logs"].map(key => {
DK[key] = key;
});
return DK;
})();
type ProvidedSampleProp = {|limit: number|};
const sampleSkeletons: Array<{|id: string, sample: ProvidedSampleProp|}> = [
{
id: 'memory',
sample: {
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 DRAWER_WIDTH = 240;
export const DURATION = 200;
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
// 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/>.
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {withStyles} from 'material-ui/styles';
import SideBar from './SideBar.jsx';
import Header from './Header.jsx';
import Main from "./Main.jsx";
import {isNullOrUndefined, LIMIT, TAGS, DATA_KEYS,} from "./Common.jsx";
import withStyles from 'material-ui/styles/withStyles';
import {lensPath, view, set} from 'ramda';
// 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 => ({
appFrame: {
position: 'relative',
display: 'flex',
width: '100%',
height: '100%',
background: theme.palette.background.default,
},
dashboard: {
display: 'flex',
flexFlow: 'column',
width: '100%',
height: '100%',
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.
// When there is an incoming message, updates the page's content correspondingly.
class Dashboard extends Component {
constructor(props) {
super(props);
this.state = {
active: TAGS.home.id, // active menu
sideBar: true, // true if the sidebar is opened
memory: [],
traffic: [],
logs: [],
shouldUpdate: {},
};
}
// componentDidMount initiates the establishment of the first websocket connection after the component is rendered.
componentDidMount() {
this.reconnect();
}
// componentDidMount initiates the establishment of the first websocket connection after the component is rendered.
componentDidMount() {
this.reconnect();
}
// reconnect establishes a websocket connection with the server, listens for incoming messages
// and tries to reconnect on connection loss.
reconnect = () => {
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
// and tries to reconnect on connection loss.
reconnect = () => {
const server = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/api");
// samples retrieves the raw data of a chart field from the incoming message.
samples = (chart: Chart) => {
let s = [];
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 => {
const msg = JSON.parse(event.data);
if (isNullOrUndefined(msg)) {
return;
}
this.update(msg);
};
// handleHome changes the home-menu related part of the state.
handleHome = (home: HomeMessage) => {
this.setState((prevState) => {
let newState = prevState;
newState.shouldUpdate = new Set();
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 = () => {
setTimeout(this.reconnect, 3000);
};
};
// handleLogs changes the logs-menu related part of the state.
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 = msg => {
console.log(msg);
this.setState(prevState => {
let newState = [];
newState.shouldUpdate = {};
const insert = (key, values, limit) => {
newState[key] = [...prevState[key], ...values];
while (newState[key].length > limit) {
newState[key].shift();
}
newState.shouldUpdate[key] = true;
};
// (Re)initialize the state with the past data.
if (!isNullOrUndefined(msg.history)) {
const memory = DATA_KEYS.memory;
const traffic = DATA_KEYS.traffic;
newState[memory] = [];
newState[traffic] = [];
if (!isNullOrUndefined(msg.history.memorySamples)) {
newState[memory] = msg.history.memorySamples.map(elem => isNullOrUndefined(elem.value) ? 0 : elem.value);
while (newState[memory].length > LIMIT.memory) {
newState[memory].shift();
}
newState.shouldUpdate[memory] = true;
}
if (!isNullOrUndefined(msg.history.trafficSamples)) {
newState[traffic] = msg.history.trafficSamples.map(elem => isNullOrUndefined(elem.value) ? 0 : elem.value);
while (newState[traffic].length > LIMIT.traffic) {
newState[traffic].shift();
}
newState.shouldUpdate[traffic] = true;
}
}
// Insert the new data samples.
if (!isNullOrUndefined(msg.memory)) {
insert(DATA_KEYS.memory, [isNullOrUndefined(msg.memory.value) ? 0 : msg.memory.value], LIMIT.memory);
}
if (!isNullOrUndefined(msg.traffic)) {
insert(DATA_KEYS.traffic, [isNullOrUndefined(msg.traffic.value) ? 0 : msg.traffic.value], LIMIT.traffic);
}
if (!isNullOrUndefined(msg.log)) {
insert(DATA_KEYS.logs, [msg.log], LIMIT.log);
}
// update analyzes the incoming message, and updates the charts' content correspondingly.
update = (msg: Message) => {
if (msg.home) {
this.handleHome(msg.home);
}
if (msg.logs) {
this.handleLogs(msg.logs);
}
};
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.
changeContent = active => {
this.setState(prevState => prevState.active !== active ? {active: active} : {});
};
// openSideBar opens the sidebar.
openSideBar = () => {
this.setState({sideBar: true});
};
openSideBar = () => {
this.setState({sideBar: true});
};
// closeSideBar closes the sidebar.
closeSideBar = () => {
this.setState({sideBar: false});
};
closeSideBar = () => {
this.setState({sideBar: false});
};
render() {
const {classes} = this.props; // The classes property is injected by withStyles().
render() {
// The classes property is injected by withStyles().
const {classes} = this.props;
return (
<div className={classes.appFrame}>
<Header
opened={this.state.sideBar}
open={this.openSideBar}
/>
<SideBar
opened={this.state.sideBar}
close={this.closeSideBar}
changeContent={this.changeContent}
/>
<Main
opened={this.state.sideBar}
active={this.state.active}
memory={this.state.memory}
traffic={this.state.traffic}
logs={this.state.logs}
shouldUpdate={this.state.shouldUpdate}
/>
</div>
);
}
return (
<div className={classes.dashboard}>
<Header
opened={this.state.sideBar}
openSideBar={this.openSideBar}
closeSideBar={this.closeSideBar}
/>
<Body
opened={this.state.sideBar}
changeContent={this.changeContent}
active={this.state.active}
content={this.state.content}
shouldUpdate={this.state.shouldUpdate}
/>
</div>
);
}
}
Dashboard.propTypes = {
classes: PropTypes.object.isRequired,
};
export default withStyles(styles)(Dashboard);

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors
// 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/>.
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {withStyles} from 'material-ui/styles';
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 Transition from 'react-transition-group/Transition';
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.
const styles = theme => ({
appBar: {
position: 'absolute',
transition: theme.transitions.create(['margin', 'width'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
},
appBarShift: {
marginLeft: DRAWER_WIDTH,
width: `calc(100% - ${DRAWER_WIDTH}px)`,
transition: theme.transitions.create(['margin', 'width'], {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
},
menuButton: {
marginLeft: 12,
marginRight: 20,
},
hide: {
display: 'none',
},
header: {
backgroundColor: theme.palette.background.appBar,
color: theme.palette.getContrastText(theme.palette.background.appBar),
zIndex: theme.zIndex.appBar,
},
toolbar: {
paddingLeft: theme.spacing.unit,
paddingRight: theme.spacing.unit,
},
mainText: {
paddingLeft: theme.spacing.unit,
},
});
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.
class Header extends Component {
render() {
// The classes property is injected by withStyles().
const {classes} = this.props;
// changeSideBar opens or closes the sidebar corresponding to the previous state.
changeSideBar = () => {
if (this.props.opened) {
this.props.closeSideBar();
} else {
this.props.openSideBar();
}
};
return (
<AppBar className={classNames(classes.appBar, this.props.opened && classes.appBarShift)}>
<Toolbar disableGutters={!this.props.opened}>
<IconButton
color="contrast"
aria-label="open drawer"
onClick={this.props.open}
className={classNames(classes.menuButton, this.props.opened && classes.hide)}
>
<MenuIcon />
</IconButton>
<Typography type="title" color="inherit" noWrap>
Go Ethereum Dashboard
</Typography>
</Toolbar>
</AppBar>
);
}
// arrowButton is connected to the sidebar; changes its state.
arrowButton = (transitionState: string) => (
<IconButton onClick={this.changeSideBar}>
<ChevronLeftIcon
style={{
...arrowDefault,
...arrowTransition[transitionState],
}}
/>
</IconButton>
);
render() {
const {classes, opened} = this.props; // The classes property is injected by withStyles().
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);

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors
// 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/>.
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.
// The children are Recharts components extended with the Material-UI's xs property.
class ChartGrid extends Component {
render() {
return (
<Grid container spacing={this.props.spacing}>
{
React.Children.map(this.props.children, child => (
<Grid item xs={child.props.xs}>
<ResponsiveContainer width="100%" height={child.props.height}>
{React.cloneElement(child, {data: child.props.values.map(value => ({value: value}))})}
</ResponsiveContainer>
</Grid>
))
}
</Grid>
);
}
}
import ChartGrid from './ChartGrid';
import type {ChartEntry} from '../types/message';
ChartGrid.propTypes = {
spacing: PropTypes.number.isRequired,
export type Props = {
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.
class Home extends Component {
shouldComponentUpdate(nextProps) {
return !isNullOrUndefined(nextProps.shouldUpdate[DATA_KEYS.memory]) ||
!isNullOrUndefined(nextProps.shouldUpdate[DATA_KEYS.traffic]);
}
shouldComponentUpdate(nextProps) {
return nextProps.shouldUpdate.has('memory') || nextProps.shouldUpdate.has('traffic');
}
render() {
const {theme} = this.props;
const memoryColor = theme.palette.primary[300];
const trafficColor = theme.palette.secondary[300];
render() {
const {memory, traffic} = this.props;
return (
<ChartGrid spacing={24}>
<AreaChart xs={6} height={300} values={this.props.memory}>
<YAxis />
<Area type="monotone" dataKey="value" stroke={memoryColor} fill={memoryColor} />
</AreaChart>
<LineChart xs={6} height={300} values={this.props.traffic}>
<Line type="monotone" dataKey="value" stroke={trafficColor} dot={false} />
</LineChart>
<LineChart xs={6} height={300} values={this.props.memory}>
<YAxis />
<CartesianGrid stroke="#eee" strokeDasharray="5 5" />
<Line type="monotone" dataKey="value" stroke={memoryColor} dot={false} />
</LineChart>
<AreaChart xs={6} height={300} values={this.props.traffic}>
<CartesianGrid stroke="#eee" strokeDasharray="5 5" vertical={false} />
<Area type="monotone" dataKey="value" stroke={trafficColor} fill={trafficColor} />
</AreaChart>
</ChartGrid>
);
}
return (
<ChartGrid spacing={24}>
<AreaChart xs={6} height={300} values={memory}>
<YAxis />
<Area type="monotone" dataKey="value" stroke={this.memoryColor} fill={this.memoryColor} />
</AreaChart>
<LineChart xs={6} height={300} values={traffic}>
<Line type="monotone" dataKey="value" stroke={this.trafficColor} dot={false} />
</LineChart>
<LineChart xs={6} height={300} values={memory}>
<YAxis />
<CartesianGrid stroke="#eee" strokeDasharray="5 5" />
<Line type="monotone" dataKey="value" stroke={this.memoryColor} dot={false} />
</LineChart>
<AreaChart xs={6} height={300} values={traffic}>
<CartesianGrid stroke="#eee" strokeDasharray="5 5" vertical={false} />
<Area type="monotone" dataKey="value" stroke={this.trafficColor} fill={this.trafficColor} />
</AreaChart>
</ChartGrid>
);
}
}
Home.propTypes = {
theme: PropTypes.object.isRequired,
shouldUpdate: PropTypes.object.isRequired,
};
export default withTheme()(Home);

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors
// 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/>.
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {withStyles} from 'material-ui/styles';
import {TAGS, DRAWER_WIDTH} from "./Common.jsx";
import Home from './Home.jsx';
import withStyles from 'material-ui/styles/withStyles';
// ContentSwitch chooses and renders the proper page content.
class ContentSwitch extends Component {
render() {
switch(this.props.active) {
case TAGS.home.id:
return <Home memory={this.props.memory} traffic={this.props.traffic} shouldUpdate={this.props.shouldUpdate} />;
case TAGS.chain.id:
return null;
case TAGS.transactions.id:
return null;
case TAGS.network.id:
// Only for testing.
return null;
case TAGS.system.id:
return null;
case TAGS.logs.id:
return <div>{this.props.logs.map((log, index) => <div key={index}>{log}</div>)}</div>;
}
return null;
}
}
import Home from './Home';
import {MENU} from './Common';
import type {Content} from '../types/content';
ContentSwitch.propTypes = {
active: PropTypes.string.isRequired,
shouldUpdate: PropTypes.object.isRequired,
};
// styles contains the styles for the Main component.
// Styles for the Content component.
const styles = theme => ({
content: {
width: '100%',
marginLeft: -DRAWER_WIDTH,
flexGrow: 1,
backgroundColor: theme.palette.background.default,
padding: theme.spacing.unit * 3,
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
marginTop: 56,
overflow: 'auto',
[theme.breakpoints.up('sm')]: {
content: {
height: 'calc(100% - 64px)',
marginTop: 64,
},
},
},
contentShift: {
marginLeft: 0,
transition: theme.transitions.create('margin', {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
},
content: {
flexGrow: 1,
backgroundColor: theme.palette.background.default,
padding: theme.spacing.unit * 3,
overflow: 'auto',
},
});
// Main renders a component for the page content.
class Main extends Component {
render() {
// The classes property is injected by withStyles().
const {classes} = this.props;
return (
<main className={classNames(classes.content, this.props.opened && classes.contentShift)}>
<ContentSwitch
active={this.props.active}
memory={this.props.memory}
traffic={this.props.traffic}
logs={this.props.logs}
shouldUpdate={this.props.shouldUpdate}
/>
</main>
);
}
}
Main.propTypes = {
classes: PropTypes.object.isRequired,
opened: PropTypes.bool.isRequired,
active: PropTypes.string.isRequired,
shouldUpdate: PropTypes.object.isRequired,
export type Props = {
classes: Object,
active: string,
content: Content,
shouldUpdate: Object,
};
// 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);

View File

@ -1,3 +1,5 @@
// @flow
// Copyright 2017 The go-ethereum Authors
// 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/>.
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.
const styles = theme => ({
drawerPaper: {
position: 'relative',
height: '100%',
width: DRAWER_WIDTH,
},
drawerHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: '0 8px',
...theme.mixins.toolbar,
transitionDuration: {
enter: theme.transitions.duration.enteringScreen,
exit: theme.transitions.duration.leavingScreen,
}
},
list: {
background: theme.palette.background.appBar,
},
listItem: {
minWidth: theme.spacing.unit * 3,
},
icon: {
fontSize: theme.spacing.unit * 3,
},
});
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.
class SideBar extends Component {
constructor(props) {
super(props);
// clickOn contains onClick event functions for the menu items.
// Instantiate only once, and reuse the existing functions to prevent the creation of
// new function instances every time the render method is triggered.
this.clickOn = {};
MENU.forEach((menu) => {
this.clickOn[menu.id] = (event) => {
event.preventDefault();
props.changeContent(menu.id);
};
});
}
// clickOn contains onClick event functions for the menu items.
// Instantiate only once, and reuse the existing functions to prevent the creation of
// new function instances every time the render method is triggered.
this.clickOn = {};
for(let key in TAGS) {
const id = TAGS[key].id;
this.clickOn[id] = event => {
event.preventDefault();
console.log(event.target.key);
this.props.changeContent(id);
};
}
}
shouldComponentUpdate(nextProps) {
return nextProps.opened !== this.props.opened;
}
render() {
// The classes property is injected by withStyles().
const {classes} = this.props;
menuItems = (transitionState) => {
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 (
<Drawer
type="persistent"
classes={{paper: classes.drawerPaper,}}
open={this.props.opened}
>
<div>
<div className={classes.drawerHeader}>
<IconButton onClick={this.props.close}>
<ChevronLeftIcon />
</IconButton>
</div>
<List>
{
Object.values(TAGS).map(tag => {
return (
<ListItem button key={tag.id} onClick={this.clickOn[tag.id]}>
<ListItemText primary={tag.title} />
</ListItem>
);
})
}
</List>
</div>
</Drawer>
);
}
// menu renders the list of the menu items.
menu = (transitionState) => {
const {classes} = this.props; // The classes property is injected by withStyles().
return (
<div className={classes.list}>
<List>
{this.menuItems(transitionState)}
</List>
</div>
);
};
render() {
return (
<Transition mountOnEnter in={this.props.opened} timeout={{enter: DURATION}}>
{this.menu}
</Transition>
);
}
}
SideBar.propTypes = {
classes: PropTypes.object.isRequired,
opened: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
changeContent: PropTypes.func.isRequired,
};
export default withStyles(styles)(SideBar);

View File

@ -0,0 +1,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
// 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/>.
import React from 'react';
import {hydrate} from 'react-dom';
import {createMuiTheme, MuiThemeProvider} from 'material-ui/styles';
import {render} from 'react-dom';
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({
palette: {
type: 'dark',
},
});
// Renders the whole dashboard.
hydrate(
<MuiThemeProvider theme={theme}>
<Dashboard />
</MuiThemeProvider>,
document.getElementById('dashboard')
);
const dashboard = document.getElementById('dashboard');
if (dashboard) {
// Renders the whole dashboard.
render(
<MuiThemeProvider theme={theme}>
<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": {
"babel-core": "^6.26.0",
"babel-eslint": "^8.0.1",
"babel-loader": "^7.1.2",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"classnames": "^2.2.5",
"eslint": "^4.5.0",
"eslint-plugin-react": "^7.4.0",
"material-ui": "^1.0.0-beta.18",
"material-ui-icons": "^1.0.0-beta.17",
"path": "^0.12.7",
"prop-types": "^15.6.0",
"recharts": "^1.0.0-beta.0",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"url": "^0.11.0",
"webpack": "^3.5.5"
}
"dependencies": {
"babel-core": "^6.26.0",
"babel-eslint": "^8.0.3",
"babel-loader": "^7.1.2",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-flow-strip-types": "^6.22.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"babel-runtime": "^6.26.0",
"classnames": "^2.2.5",
"css-loader": "^0.28.7",
"eslint": "^4.13.1",
"eslint-config-airbnb": "^16.1.0",
"eslint-loader": "^1.9.0",
"eslint-plugin-import": "^2.8.0",
"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">
<title>Go Ethereum Dashboard</title>
<link rel="shortcut icon" type="image/ico" href="https://ethereum.org/favicon.ico"/>
<!-- TODO (kurkomisi): Return to the external libraries to speed up the bundling during development -->
<link rel="shortcut icon" type="image/ico" href="https://ethereum.org/favicon.ico" />
<style>
::-webkit-scrollbar {
width: 16px;
}
::-webkit-scrollbar-thumb {
background: #212121;
}
</style>
</head>
<body style="height: 100%; margin: 0">
<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
// along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
const webpack = require('webpack');
const path = require('path');
module.exports = {
entry: './index.jsx',
output: {
path: path.resolve(__dirname, 'public'),
filename: 'bundle.js',
},
module: {
loaders: [
{
test: /\.jsx$/, // regexp for JSX files
loader: 'babel-loader', // The babel configuration is in the package.json.
query: {
presets: ['env', 'react', 'stage-0']
}
},
],
},
resolve: {
extensions: ['.js', '.jsx'],
},
entry: './index',
output: {
path: path.resolve(__dirname, 'public'),
filename: 'bundle.js',
},
plugins: [
new webpack.optimize.UglifyJsPlugin({
comments: false,
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
//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 (
"fmt"
@ -40,7 +43,7 @@ const (
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.
type Dashboard struct {
@ -48,46 +51,30 @@ type Dashboard struct {
listener net.Listener
conns map[uint32]*client // Currently live websocket connections
charts charts // The collected data samples to plot
lock sync.RWMutex // Lock protecting the dashboard's internals
charts *HomeMessage
lock sync.RWMutex // Lock protecting the dashboard's internals
quit chan chan error // Channel used for graceful exit
wg sync.WaitGroup
}
// message embraces the data samples of a client message.
type message struct {
History *charts `json:"history,omitempty"` // Past data samples
Memory *chartEntry `json:"memory,omitempty"` // One memory sample
Traffic *chartEntry `json:"traffic,omitempty"` // One traffic sample
Log string `json:"log,omitempty"` // One log
}
// client represents active websocket connection with a remote browser.
type client struct {
conn *websocket.Conn // Particular live websocket connection
msg chan message // Message queue for the update messages
msg chan Message // Message queue for the update messages
logger log.Logger // Logger for the particular live websocket connection
}
// charts contains the collected data samples.
type charts struct {
Memory []*chartEntry `json:"memorySamples,omitempty"`
Traffic []*chartEntry `json:"trafficSamples,omitempty"`
}
// chartEntry represents one data sample
type chartEntry struct {
Time time.Time `json:"time,omitempty"`
Value float64 `json:"value,omitempty"`
}
// New creates a new dashboard instance with the given configuration.
func New(config *Config) (*Dashboard, error) {
return &Dashboard{
conns: make(map[uint32]*client),
config: config,
quit: make(chan chan error),
charts: &HomeMessage{
Memory: &Chart{},
Traffic: &Chart{},
},
}, nil
}
@ -183,13 +170,13 @@ func (db *Dashboard) webHandler(w http.ResponseWriter, r *http.Request) {
// apiHandler handles requests for the dashboard.
func (db *Dashboard) apiHandler(conn *websocket.Conn) {
id := atomic.AddUint32(&nextId, 1)
id := atomic.AddUint32(&nextID, 1)
client := &client{
conn: conn,
msg: make(chan message, 128),
msg: make(chan Message, 128),
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.
db.wg.Add(1)
@ -210,8 +197,15 @@ func (db *Dashboard) apiHandler(conn *websocket.Conn) {
}
}()
// Send the past data.
client.msg <- message{
History: &db.charts,
client.msg <- Message{
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.
db.lock.Lock()
@ -245,29 +239,34 @@ func (db *Dashboard) collectData() {
inboundTraffic := metrics.DefaultRegistry.Get("p2p/InboundTraffic").(metrics.Meter).Rate1()
memoryInUse := metrics.DefaultRegistry.Get("system/memory/inuse").(metrics.Meter).Rate1()
now := time.Now()
memory := &chartEntry{
memory := &ChartEntry{
Time: now,
Value: memoryInUse,
}
traffic := &chartEntry{
traffic := &ChartEntry{
Time: now,
Value: inboundTraffic,
}
// Remove the first elements in case the samples' amount exceeds the limit.
first := 0
if len(db.charts.Memory) == memorySampleLimit {
if len(db.charts.Memory.History) == memorySampleLimit {
first = 1
}
db.charts.Memory = append(db.charts.Memory[first:], memory)
db.charts.Memory.History = append(db.charts.Memory.History[first:], memory)
first = 0
if len(db.charts.Traffic) == trafficSampleLimit {
if len(db.charts.Traffic.History) == trafficSampleLimit {
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{
Memory: memory,
Traffic: traffic,
db.sendToAll(&Message{
Home: &HomeMessage{
Memory: &Chart{
New: memory,
},
Traffic: &Chart{
New: traffic,
},
},
})
}
}
@ -277,6 +276,7 @@ func (db *Dashboard) collectData() {
func (db *Dashboard) collectLogs() {
defer db.wg.Done()
id := 1
// TODO (kurkomisi): log collection comes here.
for {
select {
@ -284,15 +284,18 @@ func (db *Dashboard) collectLogs() {
errc <- nil
return
case <-time.After(db.config.Refresh / 2):
db.sendToAll(&message{
Log: "This is a fake log.",
db.sendToAll(&Message{
Logs: &LogsMessage{
Log: fmt.Sprintf("%-4d: This is a fake log.", id),
},
})
id++
}
}
}
// sendToAll sends the given message to the active dashboards.
func (db *Dashboard) sendToAll(msg *message) {
func (db *Dashboard) sendToAll(msg *Message) {
db.lock.Lock()
for _, c := range db.conns {
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"`
}