From 64f08dcf9618ddec60ba289e75966cf20b7bed5c Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 3 Feb 2016 16:25:54 +0100 Subject: [PATCH] Metric feeder as higher order component to feed sparklines data --- .../scripts/components/animated-sparkline.js | 138 ----------------- .../node-details/node-details-health-item.js | 29 ++-- .../node-details-health-overflow-item.js | 4 +- .../node-details-health-overflow.js | 2 +- .../node-details/node-details-health.js | 2 +- client/app/scripts/components/sparkline.js | 2 +- client/app/scripts/constants/timer.js | 3 + client/app/scripts/hoc/metric-feeder.js | 145 ++++++++++++++++++ client/app/scripts/utils/string-utils.js | 10 +- client/app/scripts/utils/web-api-utils.js | 12 +- 10 files changed, 184 insertions(+), 163 deletions(-) delete mode 100644 client/app/scripts/components/animated-sparkline.js create mode 100644 client/app/scripts/constants/timer.js create mode 100644 client/app/scripts/hoc/metric-feeder.js diff --git a/client/app/scripts/components/animated-sparkline.js b/client/app/scripts/components/animated-sparkline.js deleted file mode 100644 index dde9b42b2..000000000 --- a/client/app/scripts/components/animated-sparkline.js +++ /dev/null @@ -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 ( - - ); - } - -} - -AnimatedSparkline.propTypes = { - data: React.PropTypes.array.isRequired -}; diff --git a/client/app/scripts/components/node-details/node-details-health-item.js b/client/app/scripts/components/node-details/node-details-health-item.js index e16d374ec..1c63437ec 100644 --- a/client/app/scripts/components/node-details/node-details-health-item.js +++ b/client/app/scripts/components/node-details/node-details-health-item.js @@ -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 ( -
-
{formatMetric(props.item.value, props.item)}
-
- +class NodeDetailsHealthItem extends React.Component { + render() { + return ( +
+
{formatMetric(this.props.value, this.props)}
+
+ +
+
{this.props.label}
-
{props.item.label}
-
- ); -}; + ); + } +} + +export default metricFeeder(NodeDetailsHealthItem); diff --git a/client/app/scripts/components/node-details/node-details-health-overflow-item.js b/client/app/scripts/components/node-details/node-details-health-overflow-item.js index 1e59d6cfe..bcf861e12 100644 --- a/client/app/scripts/components/node-details/node-details-health-overflow-item.js +++ b/client/app/scripts/components/node-details/node-details-health-overflow-item.js @@ -6,8 +6,8 @@ export default class NodeDetailsHealthOverflowItem extends React.Component { render() { return (
-
{formatMetric(this.props.item.value, this.props.item)}
-
{this.props.item.label}
+
{formatMetric(this.props.value, this.props)}
+
{this.props.label}
); } diff --git a/client/app/scripts/components/node-details/node-details-health-overflow.js b/client/app/scripts/components/node-details/node-details-health-overflow.js index 260786fdc..32624d631 100644 --- a/client/app/scripts/components/node-details/node-details-health-overflow.js +++ b/client/app/scripts/components/node-details/node-details-health-overflow.js @@ -8,7 +8,7 @@ export default class NodeDetailsHealthOverflow extends React.Component { return (
- {items.map(item => )} + {items.map(item => )}
Show more
diff --git a/client/app/scripts/components/node-details/node-details-health.js b/client/app/scripts/components/node-details/node-details-health.js index 3f4ecc444..cb7cf1d4d 100644 --- a/client/app/scripts/components/node-details/node-details-health.js +++ b/client/app/scripts/components/node-details/node-details-health.js @@ -32,7 +32,7 @@ export default class NodeDetailsHealth extends React.Component { return (
{primeMetrics.map(item => { - return ; + return ; })} {showOverflow && } {showLess &&
show less
} diff --git a/client/app/scripts/components/sparkline.js b/client/app/scripts/components/sparkline.js index 2dca2cc1d..028823e5c 100644 --- a/client/app/scripts/components/sparkline.js +++ b/client/app/scripts/components/sparkline.js @@ -93,7 +93,7 @@ Sparkline.propTypes = { Sparkline.defaultProps = { width: 80, - height: 16, + height: 24, strokeColor: '#7d7da8', strokeWidth: '0.5px', interpolate: 'none', diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js new file mode 100644 index 000000000..6c51f065b --- /dev/null +++ b/client/app/scripts/constants/timer.js @@ -0,0 +1,3 @@ +/* Intervals in ms */ +export const API_INTERVAL = 30000; +export const TOPOLOGY_INTERVAL = 10000; diff --git a/client/app/scripts/hoc/metric-feeder.js b/client/app/scripts/hoc/metric-feeder.js new file mode 100644 index 000000000..2218748b1 --- /dev/null +++ b/client/app/scripts/hoc/metric-feeder.js @@ -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 ; + } +}; diff --git a/client/app/scripts/utils/string-utils.js b/client/app/scripts/utils/string-utils.js index e317641e9..c1ca3eda1 100644 --- a/client/app/scripts/utils/string-utils.js +++ b/client/app/scripts/utils/string-utils.js @@ -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) { diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index b23d2dba1..7272ab654 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -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); } }); }