Metric feeder as higher order component to feed sparklines data

This commit is contained in:
David Kaltschmidt
2016-02-03 16:25:54 +01:00
parent 0a0179aeb1
commit 64f08dcf96
10 changed files with 184 additions and 163 deletions

View File

@@ -1,138 +0,0 @@
// Forked from: https://github.com/KyleAMathews/react-sparkline at commit a9d7c5203d8f240938b9f2288287aaf0478df013
import React from 'react';
import d3 from 'd3';
import { OrderedMap } from 'immutable';
import Sparkline from './sparkline';
const makeOrderedMap = OrderedMap;
const parseDate = d3.time.format.iso.parse;
const sortDate = (v, d) => d;
export default class AnimatedSparkline extends React.Component {
constructor(props, context) {
super(props, context);
this.tickTimer = null;
this.state = {
buffer: makeOrderedMap(),
movingFirst: null,
movingLast: null
};
}
componentWillMount() {
this.setState(this.updateBuffer(this.props));
}
componentWillUnmount() {
clearTimeout(this.tickTimer);
}
componentWillReceiveProps(nextProps) {
this.setState(this.updateBuffer(nextProps));
}
componentDidUpdate() {
// move sliding window one tick
if (!this.tickTimer && this.state.buffer.size > 0) {
this.tick();
}
}
updateBuffer(props) {
// merge new samples into buffer
let buffer = this.state.buffer;
const nextSamples = makeOrderedMap(props.data.map(d => [d.date, d.value]));
buffer = buffer.merge(nextSamples).sortBy(sortDate);
const state = {};
// set first/last marker of sliding window
if (buffer.size > 0) {
const bufferKeys = buffer.keySeq();
if (this.state.movingFirst === null) {
state.movingFirst = bufferKeys.first();
}
if (this.state.movingLast === null) {
state.movingLast = bufferKeys.last();
}
}
// remove old values from buffer
const movingFirst = this.state.movingFirst ? this.state.movingFirst : state.movingFirst;
state.buffer = buffer.filter((v, d) => d >= movingFirst);
return state;
}
tick() {
const { buffer } = this.state;
let { movingFirst, movingLast } = this.state;
const bufferKeys = buffer.keySeq();
if (movingLast < bufferKeys.last()) {
let firstIndex = bufferKeys.indexOf(movingFirst);
if (firstIndex > -1 && firstIndex < bufferKeys.size - 1) {
firstIndex++;
} else {
firstIndex = 0;
}
movingFirst = bufferKeys.get(firstIndex);
let lastIndex = bufferKeys.indexOf(movingLast);
if (lastIndex > -1) {
lastIndex++;
} else {
lastIndex = bufferKeys.length - 1;
}
movingLast = bufferKeys.get(lastIndex);
this.tickTimer = setTimeout(() => {
this.tickTimer = null;
this.setState({movingFirst, movingLast});
}, 900);
}
}
getGraphData() {
const firstDate = parseDate(this.props.first);
const lastDate = parseDate(this.props.last);
const { buffer } = this.state;
let movingFirstDate = parseDate(this.state.movingFirst);
let movingLastDate = parseDate(this.state.movingLast);
const lastBufferDate = parseDate(buffer.keySeq().last());
if (firstDate && movingFirstDate && firstDate < movingFirstDate) {
// first prop date is way before buffer, keeping it
movingFirstDate = firstDate;
}
if (lastDate && lastBufferDate && lastDate > lastBufferDate) {
// prop last is after buffer values, need to shift dates
const skip = lastDate - lastBufferDate;
movingLastDate -= skip;
movingFirstDate -= skip;
}
const dateFilter = d => d.date >= movingFirstDate && d.date <= movingLastDate;
const data = this.state.buffer
.map((v, k) => ({value: v, date: +parseDate(k)}))
.toIndexedSeq()
.toJS()
.filter(dateFilter);
return {movingFirstDate, movingLastDate, data};
}
render() {
const {data, movingFirstDate, movingLastDate} = this.getGraphData();
return (
<Sparkline data={data} first={movingFirstDate} last={movingLastDate} min={this.props.min} />
);
}
}
AnimatedSparkline.propTypes = {
data: React.PropTypes.array.isRequired
};

View File

@@ -1,17 +1,22 @@
import React from 'react';
import AnimatedSparkline from '../animated-sparkline';
import Sparkline from '../sparkline';
import metricFeeder from '../../hoc/metric-feeder';
import { formatMetric } from '../../utils/string-utils';
export default (props) => {
return (
<div className="node-details-health-item">
<div className="node-details-health-item-value">{formatMetric(props.item.value, props.item)}</div>
<div className="node-details-health-item-sparkline">
<AnimatedSparkline data={props.item.samples} max={props.item.max}
first={props.item.first} last={props.item.last} />
class NodeDetailsHealthItem extends React.Component {
render() {
return (
<div className="node-details-health-item">
<div className="node-details-health-item-value">{formatMetric(this.props.value, this.props)}</div>
<div className="node-details-health-item-sparkline">
<Sparkline data={this.props.samples} max={this.props.max}
first={this.props.first} last={this.props.last} />
</div>
<div className="node-details-health-item-label">{this.props.label}</div>
</div>
<div className="node-details-health-item-label">{props.item.label}</div>
</div>
);
};
);
}
}
export default metricFeeder(NodeDetailsHealthItem);

View File

@@ -6,8 +6,8 @@ export default class NodeDetailsHealthOverflowItem extends React.Component {
render() {
return (
<div className="node-details-health-overflow-item">
<div className="node-details-health-overflow-item-value">{formatMetric(this.props.item.value, this.props.item)}</div>
<div className="node-details-health-overflow-item-label truncate">{this.props.item.label}</div>
<div className="node-details-health-overflow-item-value">{formatMetric(this.props.value, this.props)}</div>
<div className="node-details-health-overflow-item-label truncate">{this.props.label}</div>
</div>
);
}

View File

@@ -8,7 +8,7 @@ export default class NodeDetailsHealthOverflow extends React.Component {
return (
<div className="node-details-health-overflow" onClick={this.props.handleClickMore}>
{items.map(item => <NodeDetailsHealthOverflowItem key={item.id} item={item} />)}
{items.map(item => <NodeDetailsHealthOverflowItem key={item.id} {...item} />)}
<div className="node-details-health-overflow-expand">
Show more
</div>

View File

@@ -32,7 +32,7 @@ export default class NodeDetailsHealth extends React.Component {
return (
<div className="node-details-health" style={{flexWrap, justifyContent}}>
{primeMetrics.map(item => {
return <NodeDetailsHealthItem key={item.id} item={item} />;
return <NodeDetailsHealthItem key={item.id} {...item} />;
})}
{showOverflow && <NodeDetailsHealthOverflow items={overflowMetrics} handleClickMore={this.handleClickMore} />}
{showLess && <div className="node-details-health-expand" onClick={this.handleClickMore}>show less</div>}

View File

@@ -93,7 +93,7 @@ Sparkline.propTypes = {
Sparkline.defaultProps = {
width: 80,
height: 16,
height: 24,
strokeColor: '#7d7da8',
strokeWidth: '0.5px',
interpolate: 'none',

View File

@@ -0,0 +1,3 @@
/* Intervals in ms */
export const API_INTERVAL = 30000;
export const TOPOLOGY_INTERVAL = 10000;

View File

@@ -0,0 +1,145 @@
import React from 'react';
import d3 from 'd3';
import { OrderedMap } from 'immutable';
const makeOrderedMap = OrderedMap;
const parseDate = d3.time.format.iso.parse;
const sortDate = (v, d) => d;
const DEFAULT_TICK_INTERVAL = 1000; // DEFAULT_TICK_INTERVAL + renderTime < 1000ms
const WINDOW_LENGTH = 30;
/**
* Higher-order component that buffers a metrics series and feeds a sliding
* window of the series to the wrapped component.
*
* Initial samples `[t0, t1, t2, ...]` will be passed as is. When new data
* `[t2, t3, t4, ...]` comes in, it will be merged into the buffer:
* `[t0, t1, t2, t3, t4, ...]`. On next `tick()` the window shifts and
* `[t1, t2, t3, ...]` will be fed to the wrapped component.
* The window slides between the dates provided by the first date of the buffer
* and `this.props.last` so that the following invariant is true:
* `this.state.movingFirst <= this.props.first < this.state.movingLast <= this.props.last`.
* Samples have to be of type `[{date: String, value: Number}, ...]`.
*/
export default ComposedComponent => class extends React.Component {
constructor(props, context) {
super(props, context);
this.tickTimer = null;
this.state = {
buffer: makeOrderedMap(),
movingFirst: null,
movingLast: null
};
}
componentWillMount() {
this.setState(this.updateBuffer(this.props));
}
componentWillUnmount() {
clearTimeout(this.tickTimer);
}
componentWillReceiveProps(nextProps) {
this.setState(this.updateBuffer(nextProps));
}
componentDidUpdate() {
// move sliding window one tick
if (!this.tickTimer && this.state.buffer.size > 0) {
this.tick();
}
}
updateBuffer(props) {
// merge new samples into buffer
let buffer = this.state.buffer;
const nextSamples = makeOrderedMap(props.samples.map(d => [d.date, d.value]));
// need to sort again after merge, some new data may have different times for old values
buffer = buffer.merge(nextSamples).sortBy(sortDate);
const state = {};
// remove old values from buffer
if (this.state.movingFirst !== null) {
buffer = buffer.filter((v, d) => d > this.state.movingFirst);
}
state.buffer = buffer;
// set first/last marker of sliding window
if (buffer.size > 1) {
const bufferKeys = buffer.keySeq();
// const firstHalf = bufferKeys.slice(0, Math.floor(buffer.size / 2));
if (this.state.movingFirst === null) {
state.movingFirst = bufferKeys.first();
}
if (this.state.movingLast === null) {
state.movingLast = bufferKeys.last();
}
}
return state;
}
tick() {
const { buffer } = this.state;
let { movingFirst, movingLast } = this.state;
const bufferKeys = buffer.keySeq();
// move the sliding window one tick, make sure to keep WINDOW_LENGTH values
if (movingLast < bufferKeys.last()) {
let firstIndex = bufferKeys.indexOf(movingFirst);
let lastIndex = bufferKeys.indexOf(movingLast);
// speed up the window if it falls behind
const step = lastIndex > 0 ? Math.round(buffer.size / lastIndex) : 1;
// only move first if we have enough values in window
const windowLength = lastIndex - firstIndex;
if (firstIndex > -1 && firstIndex < bufferKeys.size - 1 && windowLength >= WINDOW_LENGTH) {
firstIndex += step + (windowLength - WINDOW_LENGTH);
} else {
firstIndex = 0;
}
movingFirst = bufferKeys.get(firstIndex);
if (!movingFirst) {
movingFirst = bufferKeys.first();
}
if (lastIndex > -1) {
lastIndex += step;
} else {
lastIndex = bufferKeys.size - 1;
}
movingLast = bufferKeys.get(lastIndex);
if (!movingLast) {
movingLast = bufferKeys.last();
}
this.tickTimer = setTimeout(() => {
this.tickTimer = null;
this.setState({movingFirst, movingLast});
}, DEFAULT_TICK_INTERVAL);
}
}
render() {
const { buffer } = this.state;
const movingFirstDate = parseDate(this.state.movingFirst);
const movingLastDate = parseDate(this.state.movingLast);
const dateFilter = d => d.date > movingFirstDate && d.date <= movingLastDate;
const samples = buffer
.map((v, k) => ({value: v, date: +parseDate(k)}))
.toIndexedSeq()
.toJS()
.filter(dateFilter);
const lastValue = samples.length > 0 ? samples[samples.length - 1].value : null;
const slidingWindow = {first: movingFirstDate, last: movingLastDate, samples, value: lastValue};
return <ComposedComponent {...this.props} {...slidingWindow} />;
}
};

View File

@@ -1,5 +1,8 @@
import React from 'react';
import filesize from 'filesize';
import d3 from 'd3';
const formatLargeValue = d3.format('s');
const formatters = {
filesize(value) {
@@ -8,11 +11,14 @@ const formatters = {
},
number(value) {
return value;
if (value < 1100 && value >= 0) {
return Number(value).toFixed(2);
}
return formatLargeValue(value);
},
percent(value) {
return formatters.metric(value, '%');
return formatters.metric(formatters.number(value), '%');
},
metric(text, unit) {

View File

@@ -6,13 +6,13 @@ import { clearControlError, closeWebsocket, openWebsocket, receiveError,
receiveControlPipe, receiveControlPipeStatus, receiveControlSuccess,
receiveTopologies, receiveNotFound } from '../actions/app-actions';
import { API_INTERVAL, TOPOLOGY_INTERVAL } from '../constants/timer';
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = wsProto + '://' + location.host + location.pathname.replace(/\/$/, '');
const log = debug('scope:web-api-utils');
const apiTimerInterval = 10000;
const reconnectTimerInterval = 5000;
const topologyTimerInterval = apiTimerInterval;
const updateFrequency = '5s';
let socket;
@@ -95,14 +95,14 @@ export function getTopologies(options) {
receiveTopologies(res);
topologyTimer = setTimeout(function() {
getTopologies(options);
}, topologyTimerInterval / 2);
}, TOPOLOGY_INTERVAL);
},
error: function(err) {
log('Error in topology request: ' + err);
receiveError(url);
topologyTimer = setTimeout(function() {
getTopologies(options);
}, topologyTimerInterval / 2);
}, TOPOLOGY_INTERVAL / 2);
}
});
}
@@ -155,12 +155,12 @@ export function getApiDetails() {
url: url,
success: function(res) {
receiveApiDetails(res);
apiDetailsTimer = setTimeout(getApiDetails, apiTimerInterval);
apiDetailsTimer = setTimeout(getApiDetails, API_INTERVAL);
},
error: function(err) {
log('Error in api details request: ' + err);
receiveError(url);
apiDetailsTimer = setTimeout(getApiDetails, apiTimerInterval / 2);
apiDetailsTimer = setTimeout(getApiDetails, API_INTERVAL / 2);
}
});
}