Browse Source

styling of charts, and fix sync issue with event store

Jordan Schalm 8 năm trước cách đây
mục cha
commit
5ed2b25412

+ 4 - 0
dashboard/datastore.go

@@ -2,6 +2,7 @@ package dashboard
 
 import (
 	"runtime"
+	"sync"
 	"time"
 
 	log "github.com/Sirupsen/logrus"
@@ -20,6 +21,7 @@ var (
 )
 
 type dataStore struct {
+	lock sync.Mutex
 	// List of samples of RAM usage
 	ramTicks []point
 	// List of samples of number of connected clients
@@ -118,6 +120,8 @@ func (h logHook) Fire(e *log.Entry) error {
 		return nil
 	}
 
+	store.lock.Lock()
+	defer store.lock.Unlock()
 	switch event {
 	case "connect":
 		store.nClients++

+ 1 - 1
dashboard/js/src/action-creators.js

@@ -10,7 +10,7 @@ export const tick = ({ram, n_clients}) => ({
 			x: Moment(ram.x, TIME_FORMAT),
 			y: ram.y
 		},
-		n_clients: {
+		nClients: {
 			x: Moment(n_clients.x, TIME_FORMAT),
 			y: n_clients.y
 		}

+ 13 - 4
dashboard/js/src/components/App.js

@@ -7,7 +7,7 @@ const styles = {
 	container: {
 		backgroundSize: 'cover',
 		display: 'flex',
-		padding: 64,
+		padding: 32,
 		flexDirection: 'column'
 	},
 	chartContainer: {
@@ -26,6 +26,7 @@ class App extends Component {
 		ws.onclose = event => console.log(event);
 		ws.onmessage = event => {
 			const data = JSON.parse(event.data);
+			console.log(data);
 			props.dispatch(tick(data));
 		};
 
@@ -33,17 +34,25 @@ class App extends Component {
 	}
 
 	render() {
+		const {ram, nClients} = this.props;
 		return (
 			<div style={styles.container}>
-				<LineChart data={this.props.ram} />
+				<LineChart
+					data={ram.get('data').toArray()}
+					domain={[ram.get('min'), ram.get('max')]}
+					format="bytes" />
+				<LineChart
+					data={nClients.get('data').toArray()}
+					domain={[nClients.get('min'), nClients.get('max')]}
+					format="number" />
 			</div>
 		);
 	}
 }
 
 const mapStateToProps = state => ({
-	ram: state.get('ram').toArray(),
-	nClients: state.get('nClients').toArray()
+	ram: state.get('ram'),
+	nClients: state.get('nClients')
 });
 
 export default connect(mapStateToProps)(App);

+ 0 - 8
dashboard/js/src/components/App.test.js

@@ -1,8 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import App from './App';
-
-it('renders without crashing', () => {
-  const div = document.createElement('div');
-  ReactDOM.render(<App />, div);
-});

+ 28 - 10
dashboard/js/src/components/LineChart.js

@@ -1,16 +1,32 @@
 import React, { PropTypes } from 'react';
 import { VictoryAxis, VictoryChart, VictoryLine } from 'victory';
-import Moment from 'moment';
+import { formatBytes, formatNumber } from './../util';
+import moment from 'moment';
+import theme from './../theme';
 
-const LineChart = ({data}) => {
+const _formatIndependentAxis = tick => moment(tick).format('HH:mm:ss');
+
+const _formatDependentAxis = (tick, format) => (
+	format === 'bytes' ?
+		formatBytes(tick, 1) :
+		formatNumber(tick, 1)
+);
+
+const LineChart = ({data, format}) => {
 	return (
 		<VictoryChart
-			height={200}
-			width={800}>
-			<VictoryAxis // 2017-02-04T10:52:20.765730186-08:00
+			theme={theme}
+			height={150}
+			width={1000}>
+			<VictoryAxis
 				scale="time"
 				tickCount={4}
-				tickFormat={tick => Moment(tick).format('HH:mm:ss')}/>
+				tickFormat={tick => _formatIndependentAxis(tick)}/>
+			<VictoryAxis
+				dependentAxis
+				scale="linear"
+				tickCount={2}
+				tickFormat={tick => _formatDependentAxis(tick, format)} />
 			<VictoryLine data={data} />
 		</VictoryChart>
 	);
@@ -18,16 +34,18 @@ const LineChart = ({data}) => {
 
 LineChart.propTypes = {
 	data: PropTypes.arrayOf(PropTypes.shape({
-		x: PropTypes.instanceOf(Moment),
+		x: PropTypes.instanceOf(moment),
 		y: PropTypes.number
-	}))
+	})),
+	format: PropTypes.oneOf(['bytes', 'number'])
 };
 
 LineChart.defaultProps = {
 	data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({
-		x: Moment().add(n, 'minutes'),
+		x: moment().add(n, 'minutes'),
 		y: n * 100 * Math.random()
-	}))
+	})),
+	format: 'number'
 };
 
 export default LineChart;

+ 35 - 13
dashboard/js/src/reducer.js

@@ -5,8 +5,16 @@ const initialState = Immutable.Map({
 	// Keep enough points for 24hrs at 30s resolution
 	maxPoints: 24 * 60 * 2,
 	// List of points for time series charts
-	ram: Immutable.List(),
-	nClients: Immutable.List()
+	ram: Immutable.Map({
+		data: Immutable.List(),
+		min: Infinity,
+		max: -Infinity
+	}),
+	nClients: Immutable.Map({
+		data: Immutable.List(),
+		min: Infinity,
+		max: -Infinity
+	})
 });
 
 const reducer = (state = initialState, {type, payload}) => {
@@ -17,16 +25,24 @@ const reducer = (state = initialState, {type, payload}) => {
 	// of last N points, up to `maxPoints`
 	// payload = {ram: [{x, y}], nClients: [{x, y}]}
 	case ActionTypes.INIT:
-		newState = state
-			.set('ram', state.get('ram')
+		payload.ram.forEach(p => {
+			if (p.y < state.getIn(['ram', 'min'])) {
+				newState = newState.setIn(['ram', 'min'], p.y);
+			}
+			if (p.y > state.getIn(['ram', 'max'])) {
+				newState = newState.setIn(['ram', 'max'], p.y);
+			}
+		});
+		newState = newState
+			.setIn(['ram', 'data'], state.getIn(['ram', 'data'])
 				.push(...payload.ram))
-			.set('nClients', state.get('nClients')
+			.setIn(['nClients', 'data'], state.getIn(['nClients', 'data'])
 				.push(...payload.nClients));
-		if (newState.get('ram').count() > state.get('maxPoints')) {
+		if (newState.getIn(['ram', 'data']).count() > state.get('maxPoints')) {
 			newState = newState
-				.set('ram', state.get('ram')
+				.setIn(['ram', 'data'], state.getIn(['ram', 'data'])
 					.shift())
-				.set('nClients', state.get('nClients')
+				.setIn(['nClients', 'data'], state.getIn(['nClients', 'data'])
 					.shift());
 		}
 		return newState;
@@ -35,16 +51,22 @@ const reducer = (state = initialState, {type, payload}) => {
 	// chart. Removes oldest point if necessary to make space.
 	// payload = {ram: {x, y}, nClients: {x, y}}
 	case ActionTypes.TICK:
+		if (payload.ram.y < state.getIn(['ram', 'min'])) {
+			newState = newState.setIn(['ram', 'min'], payload.ram.y);
+		}
+		if (payload.ram.y > state.getIn(['ram', 'max'])) {
+			newState = newState.setIn(['ram', 'max'], payload.ram.y);
+		}
 		newState = state
-			.set('ram', state.get('ram')
+			.setIn(['ram', 'data'], state.getIn(['ram', 'data'])
 				.push(payload.ram))
-			.set('nClients', state.get('nClients')
+			.setIn(['nClients', 'data'], state.getIn(['nClients', 'data'])
 				.push(payload.nClients));
-		if (newState.get('ram').count() > state.get('maxPoints')) {
+		if (newState.getIn(['ram', 'data']).count() > state.get('maxPoints')) {
 			newState = newState
-				.set('ram', state.get('ram')
+				.setIn(['ram', 'data'], state.getIn(['ram', 'data'])
 					.shift())
-				.set('nClients', state.get('nClients')
+				.setIn(['nClients', 'data'], state.getIn(['nClients', 'data'])
 					.shift());
 		}
 		return newState;

+ 195 - 0
dashboard/js/src/theme.js

@@ -0,0 +1,195 @@
+const {assign} = Object;
+
+// Colors
+const colors = [
+	"#252525",
+	"#525252",
+	"#737373",
+	"#969696",
+	"#bdbdbd",
+	"#d9d9d9",
+	"#f0f0f0"
+];
+
+const charcoal = "#252525";
+
+// Typography
+const sansSerif = "'Gill Sans', 'Gill Sans MT', 'Ser­avek', 'Trebuchet MS', sans-serif";
+const letterSpacing = "normal";
+const fontSize = 12;
+
+// Layout
+const baseProps = {
+	width: 450,
+	height: 300,
+	padding: {
+		top: 20,
+		bottom: 5,
+		left: 80,
+		right: 10
+	},
+	colorScale: colors
+};
+
+// Labels
+const baseLabelStyles = {
+	fontFamily: sansSerif,
+	fontSize,
+	letterSpacing,
+	padding: 8,
+	fill: charcoal,
+	stroke: "transparent"
+};
+
+const centeredLabelStyles = assign({ textAnchor: "middle" }, baseLabelStyles);
+
+// Strokes
+const strokeLinecap = "round";
+const strokeLinejoin = "round";
+
+// Create the theme
+const theme = {
+	area: assign({
+		style: {
+			data: {
+				fill: charcoal
+			},
+			labels: centeredLabelStyles
+		}
+	}, baseProps),
+	axis: assign({
+		style: {
+			axis: {
+				fill: "transparent",
+				stroke: charcoal,
+				strokeWidth: 1,
+				strokeLinecap,
+				strokeLinejoin
+			},
+			axisLabel: assign({}, centeredLabelStyles, {
+				padding: 5
+			}),
+			grid: {
+				fill: "transparent",
+				stroke: "transparent"
+			},
+			ticks: {
+				fill: "transparent",
+				size: 1,
+				stroke: "transparent"
+			},
+			tickLabels: baseLabelStyles
+		}
+	}, baseProps),
+	bar: assign({
+		style: {
+			data: {
+				fill: charcoal,
+				padding: 10,
+				stroke: "transparent",
+				strokeWidth: 0,
+				width: 8
+			},
+			labels: baseLabelStyles
+		}
+	}, baseProps),
+	candlestick: assign({
+		style: {
+			data: {
+				stroke: charcoal,
+				strokeWidth: 1
+			},
+			labels: centeredLabelStyles
+		},
+		candleColors: {
+			positive: "#ffffff",
+			negative: charcoal
+		}
+	}, baseProps),
+	chart: baseProps,
+	errorbar: assign({
+		style: {
+			data: {
+				fill: "transparent",
+				stroke: charcoal,
+				strokeWidth: 2
+			},
+			labels: centeredLabelStyles
+		}
+	}, baseProps),
+	group: assign({
+		colorScale: colors
+	}, baseProps),
+	line: assign({
+		style: {
+			data: {
+				fill: "transparent",
+				stroke: charcoal,
+				strokeWidth: 2
+			},
+			labels: assign({}, baseLabelStyles, {
+				textAnchor: "start"
+			})
+		}
+	}, baseProps),
+	pie: {
+		style: {
+			data: {
+				padding: 10,
+				stroke: "transparent",
+				strokeWidth: 1
+			},
+			labels: assign({}, baseLabelStyles, {
+				padding: 20
+			})
+		},
+		colorScale: colors,
+		width: 400,
+		height: 400,
+		padding: 50
+	},
+	scatter: assign({
+		style: {
+			data: {
+				fill: charcoal,
+				stroke: "transparent",
+				strokeWidth: 0
+			},
+			labels: centeredLabelStyles
+		}
+	}, baseProps),
+	stack: assign({
+		colorScale: colors
+	}, baseProps),
+	tooltip: assign({
+		style: {
+			data: {
+				fill: "transparent",
+				stroke: "transparent",
+				strokeWidth: 0
+			},
+			labels: centeredLabelStyles,
+			flyout: {
+				stroke: charcoal,
+				strokeWidth: 1,
+				fill: "#f0f0f0"
+			}
+		},
+		flyoutProps: {
+			cornerRadius: 10,
+			pointerLength: 10
+		}
+	}, baseProps),
+	voronoi: assign({
+		style: {
+			data: {
+				fill: "transparent",
+				stroke: "transparent",
+				strokeWidth: 0
+			},
+			labels: centeredLabelStyles
+		}
+	}, baseProps)
+};
+
+export default theme;

+ 26 - 1
dashboard/js/src/util.js

@@ -1,9 +1,34 @@
 // Takes a list of strings representing action types and returns an object
 // mapping each string to itself. For instance: ['a', 'b'] => {a: 'a', b: 'b'}
-export const createActionTypes = (list) => {
+export const createActionTypes = list => {
 	const types = {};
 	for (let i = 0; i < list.length; i++) {
 		types[list[i]] = list[i];
 	}
 	return types;
 };
+
+export const formatBytes = (bytes, decimals) => {
+	console.log(bytes);
+	if (bytes < 1000) return `${bytes} B`;
+	const k = 1000;
+	const dm = decimals || 3;
+	const sizes = ['B', 'KB', 'MB', 'GB'];
+	const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+	if (i < 0) return '';
+	return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
+};
+
+export const formatNumber = (num, decimals) => {
+	console.log(num);
+	if (num < 1000) return `${num}`;
+	const k = 1000;
+	const dm = decimals || 3;
+	const sizes = ['', 'K', 'M', 'B'];
+	const i = Math.floor(Math.log(num) / Math.log(k));
+
+	if (i < 0) return '';
+	console.log(parseFloat((num / Math.pow(k, i)).toFixed(dm)));
+	return `${parseFloat((num / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
+}