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;