From a8809cadfd8214bd326d47635d2cd2f2f71bd166 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 4 Dec 2015 17:54:40 +0100 Subject: [PATCH 1/7] Animate sparklines by feeding data item by item * Refactored sparklines to be rendered by react * Correct row key for sparklines * Extracted data feed into animated-sparkline * last value is rendered by sparkline now, because it relies on the last value that it is fed, not the lastest availble value --- .../scripts/components/animated-sparkline.js | 127 +++++++++++++ client/app/scripts/components/sparkline.js | 171 ++++++++---------- 2 files changed, 198 insertions(+), 100 deletions(-) create mode 100644 client/app/scripts/components/animated-sparkline.js diff --git a/client/app/scripts/components/animated-sparkline.js b/client/app/scripts/components/animated-sparkline.js new file mode 100644 index 0000000000..456f7105c2 --- /dev/null +++ b/client/app/scripts/components/animated-sparkline.js @@ -0,0 +1,127 @@ +// 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; + +export default class AnimatedSparkline extends React.Component { + + constructor(props, context) { + super(props, context); + + this.tickTimer = null; + this.state = { + buffer: makeOrderedMap(), + first: null, + last: 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); + const state = {}; + + // set first/last marker of sliding window + if (buffer.size > 0) { + const bufferKeys = buffer.keySeq(); + if (this.state.first === null) { + state.first = bufferKeys.first(); + } + if (this.state.last === null) { + state.last = bufferKeys.last(); + } + } + + // remove old values from buffer + const first = this.state.first ? this.state.first : state.first; + state.buffer = buffer.skipWhile((v, d) => d < first); + + return state; + } + + tick() { + if (this.state.last < this.state.buffer.keySeq().last()) { + const dates = this.state.buffer.keySeq(); + let firstIndex = dates.indexOf(this.state.first); + if (firstIndex > -1 && firstIndex < dates.size - 1) { + firstIndex++; + } else { + firstIndex = 0; + } + const first = dates.get(firstIndex); + + let lastIndex = dates.indexOf(this.state.last); + if (lastIndex > -1) { + lastIndex++; + } else { + lastIndex = dates.length - 1; + } + const last = dates.get(lastIndex); + + this.tickTimer = setTimeout(() => { + this.tickTimer = null; + this.setState({first, last}); + }, 900); + } + } + + getGraphData() { + let first = this.state.first; + if (this.props.first && this.props.first < this.state.first) { + // first prop date is way before buffer, keeping it + first = this.props.first; + } + let last = this.state.last; + if (this.props.last && this.props.last > this.state.buffer.keySeq().last()) { + // prop last is after buffer values, need to shift dates + const skip = parseDate(this.props.last) - parseDate(this.state.buffer.keySeq().last()); + last -= skip; + first -= skip; + } + const dateFilter = d => d.date >= first && d.date <= last; + const data = this.state.buffer.map((v, k) => { + return {value: v, date: k}; + }).toIndexedSeq().toJS().filter(dateFilter); + + return {first, last, data}; + } + + render() { + const {data, first, last} = this.getGraphData(); + + return ( + + ); + } + +} + +AnimatedSparkline.propTypes = { + data: React.PropTypes.array.isRequired +}; diff --git a/client/app/scripts/components/sparkline.js b/client/app/scripts/components/sparkline.js index 6831d5654e..5b79625efb 100644 --- a/client/app/scripts/components/sparkline.js +++ b/client/app/scripts/components/sparkline.js @@ -1,126 +1,97 @@ // Forked from: https://github.com/KyleAMathews/react-sparkline at commit a9d7c5203d8f240938b9f2288287aaf0478df013 import React from 'react'; -import ReactDOM from 'react-dom'; import d3 from 'd3'; +const parseDate = d3.time.format.iso.parse; + export default class Sparkline extends React.Component { - componentDidMount() { - return this.renderSparkline(); - } - renderSparkline() { - // If the sparkline has already been rendered, remove it. - const el = ReactDOM.findDOMNode(this); - while (el.firstChild) { - el.removeChild(el.firstChild); - } + constructor(props, context) { + super(props, context); + this.x = d3.scale.linear(); + this.y = d3.scale.linear(); + this.line = d3.svg.line() + .x(d => this.x(d.date)) + .y(d => this.y(d.value)); + } - const data = this.props.data.slice(); + getGraphData() { + // data is of shape [{date, value}, ...] and is sorted by date (ASC) + let data = this.props.data; - // Do nothing if no data is passed in. - if (data.length === 0) { - return; + // Do nothing if no data or data w/o date are passed in. + if (data.length === 0 || data[0].date === undefined) { + return
; } - const x = d3.scale.linear().range([2, this.props.width - 2]); - const y = d3.scale.linear().range([this.props.height - 2, 2]); - - // react-sparkline allows you to pass in two types of data. - // Data tied to dates and linear data. We need to change our line and x/y - // functions depending on the type of data. - - // These are objects with a date key - let line; - let lastX; - let lastY; - let title; - if (data[0].date) { - // Convert dates into D3 dates - data.forEach(d => { - d.date = d3.time.format.iso.parse(d.date); - }); - - line = d3.svg.line(). - interpolate(this.props.interpolate). - x(d => x(d.date)). - y(d => y(d.value)); - - const first = this.props.first ? d3.time.format.iso.parse(this.props.first) : d3.min(data, d => d.date); - const last = this.props.last ? d3.time.format.iso.parse(this.props.last) : d3.max(data, d => d.date); - x.domain([first, last]); - - y.domain([ - this.props.min || d3.min(data, d => d.value), - this.props.max || d3.max(data, d => d.value) - ]); - - lastX = x(data[data.length - 1].date); - lastY = y(data[data.length - 1].value); - title = 'Last ' + d3.round((last - first) / 1000) + ' seconds, ' + data.length + ' samples, min: ' + d3.round(d3.min(data, d => d.value), 2) + ', max: ' + d3.round(d3.max(data, d => d.value), 2) + ', mean: ' + d3.round(d3.mean(data, d => d.value), 2); - } else { - line = d3.svg.line(). - interpolate(this.props.interpolate). - x((d, i) => x(i)). - y(d => y(d)); - - x.domain([ - this.props.first || 0, - this.props.last || data.length - ]); - - y.domain([ - this.props.min || d3.min(data), - this.props.max || d3.max(data) - ]); - - lastX = x(data.length - 1); - lastY = y(data[data.length - 1]); - title = data.length + ' samples, min: ' + d3.round(d3.min(data), 2) + ', max: ' + d3.round(d3.max(data), 2) + ', mean: ' + d3.round(d3.mean(data), 2); + // adjust scales + this.x.range([2, this.props.width - 2]); + this.y.range([this.props.height - 2, 2]); + this.line.interpolate(this.props.interpolate); + + // Convert dates into D3 dates + data = data.map(d => { + return { + date: parseDate(d.date), + value: d.value + }; + }); + + // determine date range + let firstDate = this.props.first ? parseDate(this.props.first) : data[0].date; + let lastDate = this.props.last ? parseDate(this.props.last) : data[data.length - 1].date; + // if last prop is after last value, we need to add that difference as + // padding before first value to right-align sparkline + const skip = lastDate - data[data.length - 1].date; + if (skip > 0) { + firstDate -= skip; + lastDate -= skip; } - - d3.select(ReactDOM.findDOMNode(this)).attr('title', title); - - const svg = d3.select(ReactDOM.findDOMNode(this)). - append('svg'). - attr('width', this.props.width). - attr('height', this.props.height). - append('g'); - - svg.append('path'). - datum(data). - attr('class', 'sparkline'). - style('fill', 'none'). - style('stroke', this.props.strokeColor). - style('stroke-width', this.props.strokeWidth). - attr('d', line); - - svg.append('circle'). - attr('class', 'sparkcircle'). - attr('cx', lastX). - attr('cy', lastY). - attr('fill', '#46466a'). - attr('fill-opacity', 0.6). - attr('stroke', 'none'). - attr('r', this.props.circleDiameter); + this.x.domain([firstDate, lastDate]); + + // determine value range + const minValue = this.props.min !== undefined ? this.props.min : d3.min(data, d => d.value); + const maxValue = this.props.max !== undefined ? Math.max(this.props.max, d3.max(data, d => d.value)) : d3.max(data, d => d.value); + this.y.domain([minValue, maxValue]); + + const lastValue = data[data.length - 1].value; + const lastX = this.x(lastDate); + const lastY = this.y(lastValue); + const title = 'Last ' + d3.round((lastDate - firstDate) / 1000) + ' seconds, ' + + data.length + ' samples, min: ' + d3.round(d3.min(data, d => d.value), 2) + + ', max: ' + d3.round(d3.max(data, d => d.value), 2) + + ', mean: ' + d3.round(d3.mean(data, d => d.value), 2); + + return {title, lastValue, lastX, lastY, data}; } render() { + const {lastValue, lastX, lastY, title, data} = this.getGraphData(); + return ( -
+
+ + + + + {lastValue} +
); } - componentDidUpdate() { - return this.renderSparkline(); - } } +Sparkline.propTypes = { + data: React.PropTypes.array.isRequired +}; + Sparkline.defaultProps = { width: 80, height: 16, strokeColor: '#7d7da8', strokeWidth: '0.5px', - interpolate: 'basis', - circleDiameter: 1.75, - data: [1, 23, 5, 5, 23, 0, 0, 0, 4, 32, 3, 12, 3, 1, 24, 1, 5, 5, 24, 23] // Some semi-random data. + interpolate: 'none', + circleDiameter: 1.75 }; From 0a0179aeb11b0c63cf1320235da9982589bb8842 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Tue, 2 Feb 2016 18:06:33 +0100 Subject: [PATCH 2/7] Integrated animated sparklines into new details panel also added sortBy date after merge in value buffer --- .../scripts/components/animated-sparkline.js | 79 +++++++++++-------- .../node-details/node-details-health-item.js | 6 +- client/app/scripts/components/sparkline.js | 10 ++- 3 files changed, 55 insertions(+), 40 deletions(-) diff --git a/client/app/scripts/components/animated-sparkline.js b/client/app/scripts/components/animated-sparkline.js index 456f7105c2..dde9b42b2d 100644 --- a/client/app/scripts/components/animated-sparkline.js +++ b/client/app/scripts/components/animated-sparkline.js @@ -7,6 +7,7 @@ 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 { @@ -16,8 +17,8 @@ export default class AnimatedSparkline extends React.Component { this.tickTimer = null; this.state = { buffer: makeOrderedMap(), - first: null, - last: null + movingFirst: null, + movingLast: null }; } @@ -44,79 +45,89 @@ export default class AnimatedSparkline extends React.Component { // 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); + 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.first === null) { - state.first = bufferKeys.first(); + if (this.state.movingFirst === null) { + state.movingFirst = bufferKeys.first(); } - if (this.state.last === null) { - state.last = bufferKeys.last(); + if (this.state.movingLast === null) { + state.movingLast = bufferKeys.last(); } } // remove old values from buffer - const first = this.state.first ? this.state.first : state.first; - state.buffer = buffer.skipWhile((v, d) => d < first); + const movingFirst = this.state.movingFirst ? this.state.movingFirst : state.movingFirst; + state.buffer = buffer.filter((v, d) => d >= movingFirst); return state; } tick() { - if (this.state.last < this.state.buffer.keySeq().last()) { - const dates = this.state.buffer.keySeq(); - let firstIndex = dates.indexOf(this.state.first); - if (firstIndex > -1 && firstIndex < dates.size - 1) { + 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; } - const first = dates.get(firstIndex); + movingFirst = bufferKeys.get(firstIndex); - let lastIndex = dates.indexOf(this.state.last); + let lastIndex = bufferKeys.indexOf(movingLast); if (lastIndex > -1) { lastIndex++; } else { - lastIndex = dates.length - 1; + lastIndex = bufferKeys.length - 1; } - const last = dates.get(lastIndex); + movingLast = bufferKeys.get(lastIndex); this.tickTimer = setTimeout(() => { this.tickTimer = null; - this.setState({first, last}); + this.setState({movingFirst, movingLast}); }, 900); } } getGraphData() { - let first = this.state.first; - if (this.props.first && this.props.first < this.state.first) { + 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 - first = this.props.first; + movingFirstDate = firstDate; } - let last = this.state.last; - if (this.props.last && this.props.last > this.state.buffer.keySeq().last()) { + if (lastDate && lastBufferDate && lastDate > lastBufferDate) { // prop last is after buffer values, need to shift dates - const skip = parseDate(this.props.last) - parseDate(this.state.buffer.keySeq().last()); - last -= skip; - first -= skip; + const skip = lastDate - lastBufferDate; + movingLastDate -= skip; + movingFirstDate -= skip; } - const dateFilter = d => d.date >= first && d.date <= last; - const data = this.state.buffer.map((v, k) => { - return {value: v, date: k}; - }).toIndexedSeq().toJS().filter(dateFilter); - - return {first, last, data}; + 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, first, last} = this.getGraphData(); + const {data, movingFirstDate, movingLastDate} = this.getGraphData(); return ( - + ); } 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 7ddeba23fe..e16d374ec5 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,6 +1,6 @@ import React from 'react'; -import Sparkline from '../sparkline'; +import AnimatedSparkline from '../animated-sparkline'; import { formatMetric } from '../../utils/string-utils'; export default (props) => { @@ -8,8 +8,8 @@ export default (props) => {
{formatMetric(props.item.value, props.item)}
- +
{props.item.label}
diff --git a/client/app/scripts/components/sparkline.js b/client/app/scripts/components/sparkline.js index 5b79625efb..2dca2cc1d6 100644 --- a/client/app/scripts/components/sparkline.js +++ b/client/app/scripts/components/sparkline.js @@ -62,11 +62,16 @@ export default class Sparkline extends React.Component { ', max: ' + d3.round(d3.max(data, d => d.value), 2) + ', mean: ' + d3.round(d3.mean(data, d => d.value), 2); - return {title, lastValue, lastX, lastY, data}; + return {title, lastX, lastY, data}; } render() { - const {lastValue, lastX, lastY, title, data} = this.getGraphData(); + // Do nothing if no data or data w/o date are passed in. + if (this.props.data.length === 0 || this.props.data[0].date === undefined) { + return
; + } + + const {lastX, lastY, title, data} = this.getGraphData(); return (
@@ -76,7 +81,6 @@ export default class Sparkline extends React.Component { - {lastValue}
); } From 64f08dcf9618ddec60ba289e75966cf20b7bed5c Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 3 Feb 2016 16:25:54 +0100 Subject: [PATCH 3/7] 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 dde9b42b2d..0000000000 --- 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 e16d374ec5..1c63437ec6 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 1e59d6cfe0..bcf861e127 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 260786fdc1..32624d631e 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 3f4ecc444c..cb7cf1d4db 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 2dca2cc1d6..028823e5c8 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 0000000000..6c51f065b2 --- /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 0000000000..2218748b14 --- /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 e317641e9d..c1ca3eda18 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 b23d2dba12..7272ab6546 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); } }); } From d46f1a9d3c020bdb8e7514b0f739d6c79e27c433 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Wed, 3 Feb 2016 16:35:03 +0100 Subject: [PATCH 4/7] dont format metadata --- .../app/scripts/components/node-details/node-details-table.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/app/scripts/components/node-details/node-details-table.js b/client/app/scripts/components/node-details/node-details-table.js index f3137ecf07..ce0c9cc4f7 100644 --- a/client/app/scripts/components/node-details/node-details-table.js +++ b/client/app/scripts/components/node-details/node-details-table.js @@ -60,6 +60,7 @@ export default class NodeDetailsTable extends React.Component { ['metrics', 'metadata'].forEach(collection => { if (node[collection]) { node[collection].forEach(field => { + field.valueType = collection; values[field.id] = field; }); } @@ -118,7 +119,7 @@ export default class NodeDetailsTable extends React.Component { if (field) { return ( - {formatMetric(field.value, field)} + {field.valueType === 'metadata' ? field.value : formatMetric(field.value, field)} ); } From 8f83a7307201dd1a00195e3457e4d72b556f2b5e Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 4 Feb 2016 16:56:07 +0100 Subject: [PATCH 5/7] Split samples and feed metrics right away * also increase sparkline feed to 60 secs * and keep historic max --- client/app/scripts/constants/timer.js | 2 +- client/app/scripts/hoc/metric-feeder.js | 102 +++++++++++++----------- 2 files changed, 57 insertions(+), 47 deletions(-) diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js index 6c51f065b2..8c8d4dc4e0 100644 --- a/client/app/scripts/constants/timer.js +++ b/client/app/scripts/constants/timer.js @@ -1,3 +1,3 @@ /* Intervals in ms */ export const API_INTERVAL = 30000; -export const TOPOLOGY_INTERVAL = 10000; +export const TOPOLOGY_INTERVAL = 5000; diff --git a/client/app/scripts/hoc/metric-feeder.js b/client/app/scripts/hoc/metric-feeder.js index 2218748b14..306d928993 100644 --- a/client/app/scripts/hoc/metric-feeder.js +++ b/client/app/scripts/hoc/metric-feeder.js @@ -6,7 +6,7 @@ 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; +const WINDOW_LENGTH = 60; /** * Higher-order component that buffers a metrics series and feeds a sliding @@ -19,7 +19,9 @@ const WINDOW_LENGTH = 30; * 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}, ...]`. + * This component also keeps a historic max of all samples it sees over time. */ export default ComposedComponent => class extends React.Component { @@ -29,6 +31,7 @@ export default ComposedComponent => class extends React.Component { this.tickTimer = null; this.state = { buffer: makeOrderedMap(), + max: 0, movingFirst: null, movingLast: null }; @@ -47,10 +50,11 @@ export default ComposedComponent => class extends React.Component { } componentDidUpdate() { - // move sliding window one tick - if (!this.tickTimer && this.state.buffer.size > 0) { - this.tick(); - } + this.tick(); + } + + componentDidMount() { + this.tick(); } updateBuffer(props) { @@ -67,16 +71,19 @@ export default ComposedComponent => class extends React.Component { } state.buffer = buffer; + // set historic max + state.max = Math.max(buffer.max(), this.state.max); + // 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)); + const firstPart = bufferKeys.slice(0, Math.floor(buffer.size / 3)); if (this.state.movingFirst === null) { - state.movingFirst = bufferKeys.first(); + state.movingFirst = firstPart.first(); } if (this.state.movingLast === null) { - state.movingLast = bufferKeys.last(); + state.movingLast = firstPart.last(); } } @@ -84,49 +91,52 @@ export default ComposedComponent => class extends React.Component { } 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(); - } + // only tick after setTimeout -> setState -> componentDidUpdate + if (!this.tickTimer) { + const { buffer } = this.state; + let { movingFirst, movingLast } = this.state; + const bufferKeys = buffer.keySeq(); - if (lastIndex > -1) { - lastIndex += step; - } else { - lastIndex = bufferKeys.size - 1; + // move the sliding window one tick, make sure to keep WINDOW_LENGTH values + if (buffer.size > 0 && 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); } - 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 { buffer, max } = this.state; const movingFirstDate = parseDate(this.state.movingFirst); const movingLastDate = parseDate(this.state.movingLast); @@ -138,7 +148,7 @@ export default ComposedComponent => class extends React.Component { .filter(dateFilter); const lastValue = samples.length > 0 ? samples[samples.length - 1].value : null; - const slidingWindow = {first: movingFirstDate, last: movingLastDate, samples, value: lastValue}; + const slidingWindow = {first: movingFirstDate, last: movingLastDate, max, samples, value: lastValue}; return ; } From 7d73590ceda3ad8374a01e9284bcc00010b25a48 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 4 Feb 2016 17:17:11 +0100 Subject: [PATCH 6/7] Enable metric feeder also for health overflow items * tried activating it for child tables too, but that became quite CPU intensive, and feed intervals became out of sync (host CPU < container CPU, which is hard to believe) --- .../node-details-health-overflow-item.js | 5 ++++- .../node-details-table-node-metric.js | 15 +++++++++++++++ .../components/node-details/node-details-table.js | 15 +++++++++------ client/app/scripts/hoc/metric-feeder.js | 3 +++ client/app/styles/main.less | 2 +- 5 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 client/app/scripts/components/node-details/node-details-table-node-metric.js 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 bcf861e127..bb19447656 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 @@ -1,8 +1,9 @@ import React from 'react'; +import metricFeeder from '../../hoc/metric-feeder'; import { formatMetric } from '../../utils/string-utils'; -export default class NodeDetailsHealthOverflowItem extends React.Component { +class NodeDetailsHealthOverflowItem extends React.Component { render() { return (
@@ -12,3 +13,5 @@ export default class NodeDetailsHealthOverflowItem extends React.Component { ); } } + +export default metricFeeder(NodeDetailsHealthOverflowItem); diff --git a/client/app/scripts/components/node-details/node-details-table-node-metric.js b/client/app/scripts/components/node-details/node-details-table-node-metric.js new file mode 100644 index 0000000000..c27c12ab8f --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-table-node-metric.js @@ -0,0 +1,15 @@ +import React from 'react'; + +import { formatMetric } from '../../utils/string-utils'; + +class NodeDetailsTableNodeMetric extends React.Component { + render() { + return ( + + {formatMetric(this.props.value, this.props)} + + ); + } +} + +export default NodeDetailsTableNodeMetric; diff --git a/client/app/scripts/components/node-details/node-details-table.js b/client/app/scripts/components/node-details/node-details-table.js index ce0c9cc4f7..062c82029c 100644 --- a/client/app/scripts/components/node-details/node-details-table.js +++ b/client/app/scripts/components/node-details/node-details-table.js @@ -2,7 +2,7 @@ import _ from 'lodash'; import React from 'react'; import NodeDetailsTableNodeLink from './node-details-table-node-link'; -import { formatMetric } from '../../utils/string-utils'; +import NodeDetailsTableNodeMetric from './node-details-table-node-metric'; export default class NodeDetailsTable extends React.Component { @@ -117,11 +117,14 @@ export default class NodeDetailsTable extends React.Component { return this.props.columns.map(col => { const field = fields[col]; if (field) { - return ( - - {field.valueType === 'metadata' ? field.value : formatMetric(field.value, field)} - - ); + if (field.valueType === 'metadata') { + return ( + + {field.value} + + ); + } + return ; } }); } diff --git a/client/app/scripts/hoc/metric-feeder.js b/client/app/scripts/hoc/metric-feeder.js index 306d928993..5665f13038 100644 --- a/client/app/scripts/hoc/metric-feeder.js +++ b/client/app/scripts/hoc/metric-feeder.js @@ -149,6 +149,9 @@ export default ComposedComponent => class extends React.Component { const lastValue = samples.length > 0 ? samples[samples.length - 1].value : null; const slidingWindow = {first: movingFirstDate, last: movingLastDate, max, samples, value: lastValue}; + if (this.props.label === 'CPU') { + console.log(lastValue, movingLastDate); + } return ; } diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 2d80548591..72840f73be 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -717,7 +717,7 @@ h2 { } } - &-value { + &-value, &-metric { flex: 1; margin-left: 0.5em; text-align: right; From cd66819f6b5698cf504c54cdf329e8d972225abe Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Thu, 4 Feb 2016 18:13:53 +0100 Subject: [PATCH 7/7] Removes console.log --- client/app/scripts/hoc/metric-feeder.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/app/scripts/hoc/metric-feeder.js b/client/app/scripts/hoc/metric-feeder.js index 5665f13038..306d928993 100644 --- a/client/app/scripts/hoc/metric-feeder.js +++ b/client/app/scripts/hoc/metric-feeder.js @@ -149,9 +149,6 @@ export default ComposedComponent => class extends React.Component { const lastValue = samples.length > 0 ? samples[samples.length - 1].value : null; const slidingWindow = {first: movingFirstDate, last: movingLastDate, max, samples, value: lastValue}; - if (this.props.label === 'CPU') { - console.log(lastValue, movingLastDate); - } return ; }