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 7ddeba23f..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 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..bb1944765 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,14 +1,17 @@ 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 (
-
{formatMetric(this.props.item.value, this.props.item)}
-
{this.props.item.label}
+
{formatMetric(this.props.value, this.props)}
+
{this.props.label}
); } } + +export default metricFeeder(NodeDetailsHealthOverflowItem); 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/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 000000000..c27c12ab8 --- /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 f3137ecf0..062c82029 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 { @@ -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; }); } @@ -116,11 +117,14 @@ export default class NodeDetailsTable extends React.Component { return this.props.columns.map(col => { const field = fields[col]; if (field) { - return ( - - {formatMetric(field.value, field)} - - ); + if (field.valueType === 'metadata') { + return ( + + {field.value} + + ); + } + return ; } }); } diff --git a/client/app/scripts/components/sparkline.js b/client/app/scripts/components/sparkline.js index 6831d5654..028823e5c 100644 --- a/client/app/scripts/components/sparkline.js +++ b/client/app/scripts/components/sparkline.js @@ -1,126 +1,101 @@ // 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(); + + 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)); } - renderSparkline() { - // If the sparkline has already been rendered, remove it. - const el = ReactDOM.findDOMNode(this); - while (el.firstChild) { - el.removeChild(el.firstChild); + getGraphData() { + // data is of shape [{date, value}, ...] and is sorted by date (ASC) + let data = this.props.data; + + // Do nothing if no data or data w/o date are passed in. + if (data.length === 0 || data[0].date === undefined) { + return
; } - const data = this.props.data.slice(); + // adjust scales + this.x.range([2, this.props.width - 2]); + this.y.range([this.props.height - 2, 2]); + this.line.interpolate(this.props.interpolate); - // Do nothing if no data is passed in. - if (data.length === 0) { - return; + // 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; } + this.x.domain([firstDate, lastDate]); - const x = d3.scale.linear().range([2, this.props.width - 2]); - const y = d3.scale.linear().range([this.props.height - 2, 2]); + // 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]); - // 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. + 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); - // 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); - } - - 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); + return {title, lastX, lastY, data}; } render() { + // 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 ( -
+
+ + + + +
); } - componentDidUpdate() { - return this.renderSparkline(); - } } +Sparkline.propTypes = { + data: React.PropTypes.array.isRequired +}; + Sparkline.defaultProps = { width: 80, - height: 16, + height: 24, 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 }; diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js new file mode 100644 index 000000000..8c8d4dc4e --- /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 = 5000; diff --git a/client/app/scripts/hoc/metric-feeder.js b/client/app/scripts/hoc/metric-feeder.js new file mode 100644 index 000000000..306d92899 --- /dev/null +++ b/client/app/scripts/hoc/metric-feeder.js @@ -0,0 +1,155 @@ +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 = 60; + +/** + * 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}, ...]`. + * This component also keeps a historic max of all samples it sees over time. + */ +export default ComposedComponent => class extends React.Component { + + constructor(props, context) { + super(props, context); + + this.tickTimer = null; + this.state = { + buffer: makeOrderedMap(), + max: 0, + movingFirst: null, + movingLast: null + }; + } + + componentWillMount() { + this.setState(this.updateBuffer(this.props)); + } + + componentWillUnmount() { + clearTimeout(this.tickTimer); + } + + componentWillReceiveProps(nextProps) { + this.setState(this.updateBuffer(nextProps)); + } + + componentDidUpdate() { + this.tick(); + } + + componentDidMount() { + 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 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 firstPart = bufferKeys.slice(0, Math.floor(buffer.size / 3)); + + if (this.state.movingFirst === null) { + state.movingFirst = firstPart.first(); + } + if (this.state.movingLast === null) { + state.movingLast = firstPart.last(); + } + } + + return state; + } + + tick() { + // only tick after setTimeout -> setState -> componentDidUpdate + if (!this.tickTimer) { + 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 (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); + } + } + } + + render() { + const { buffer, max } = 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, max, 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); } }); } diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 2d8054859..72840f73b 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;