diff --git a/Makefile b/Makefile
index 6b8ff6568..528491b00 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@ CODECGEN_EXE=$(CODECGEN_DIR)/bin/codecgen_$(shell go env GOHOSTOS)_$(shell go en
GET_CODECGEN_DEPS=$(shell find $(1) -maxdepth 1 -type f -name '*.go' -not -name '*_test.go' -not -name '*.codecgen.go' -not -name '*.generated.go')
CODECGEN_TARGETS=report/report.codecgen.go render/render.codecgen.go render/detailed/detailed.codecgen.go
RM=--rm
-RUN_FLAGS=-ti
+RUN_FLAGS=-i
BUILD_IN_CONTAINER=true
GO_ENV=GOGC=off
GO=env $(GO_ENV) go
diff --git a/client/.eslintignore b/client/.eslintignore
index 7ce46b3a1..507516460 100644
--- a/client/.eslintignore
+++ b/client/.eslintignore
@@ -1 +1,2 @@
app/scripts/vendor/term.js
+test/
diff --git a/client/.eslintrc b/client/.eslintrc
index 643e0f06c..aa6943f9e 100644
--- a/client/.eslintrc
+++ b/client/.eslintrc
@@ -9,6 +9,7 @@
"comma-dangle": 0,
"object-curly-spacing": 0,
"react/jsx-closing-bracket-location": 0,
+ "react/prefer-stateless-function": 0,
"react/sort-comp": 0,
"react/prop-types": 0
}
diff --git a/client/.gitignore b/client/.gitignore
index 74baa49d8..034f1b004 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -2,3 +2,4 @@ node_modules
build/app.js
build/*[woff2?|ttf|eot|svg]
coverage/
+test/*png
diff --git a/client/app/scripts/charts/edge-container.js b/client/app/scripts/charts/edge-container.js
new file mode 100644
index 000000000..e6b840595
--- /dev/null
+++ b/client/app/scripts/charts/edge-container.js
@@ -0,0 +1,96 @@
+import _ from 'lodash';
+import d3 from 'd3';
+import React from 'react';
+import { Motion, spring } from 'react-motion';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
+import { Map as makeMap } from 'immutable';
+
+import Edge from './edge';
+
+const animConfig = [80, 20]; // stiffness, damping
+const pointCount = 30;
+
+const line = d3.svg.line()
+ .interpolate('basis')
+ .x(d => d.x)
+ .y(d => d.y);
+
+const buildPath = (points, layoutPrecision) => {
+ const extracted = [];
+ _.each(points, (value, key) => {
+ const axis = key[0];
+ const index = key.slice(1);
+ if (!extracted[index]) {
+ extracted[index] = {};
+ }
+ extracted[index][axis] = d3.round(value, layoutPrecision);
+ });
+ return extracted;
+};
+
+export default class EdgeContainer extends React.Component {
+
+ constructor(props, context) {
+ super(props, context);
+ this.state = {
+ pointsMap: makeMap()
+ };
+ }
+
+ componentWillMount() {
+ this.preparePoints(this.props.points);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ this.preparePoints(nextProps.points);
+ }
+
+ render() {
+ const { layoutPrecision, points } = this.props;
+ const other = _.omit(this.props, 'points');
+
+ if (layoutPrecision === 0) {
+ const path = line(points.toJS());
+ return ;
+ }
+
+ return (
+
+ {(interpolated) => {
+ // convert points to path string, because that lends itself to
+ // JS-equality checks in the child component
+ const path = line(buildPath(interpolated, layoutPrecision));
+ return ;
+ }}
+
+ );
+ }
+
+ preparePoints(nextPoints) {
+ // Spring needs constant field count, hoping that dagre will insert never more than `pointCount`
+ let { pointsMap } = this.state;
+
+ // filling up the map with copies of the first point
+ const filler = nextPoints.first();
+ const missing = pointCount - nextPoints.size;
+ let index = 0;
+ if (missing > 0) {
+ while (index < missing) {
+ pointsMap = pointsMap.set(`x${index}`, spring(filler.get('x'), animConfig));
+ pointsMap = pointsMap.set(`y${index}`, spring(filler.get('y'), animConfig));
+ index++;
+ }
+ }
+
+ nextPoints.forEach((point, i) => {
+ pointsMap = pointsMap.set(`x${index + i}`, spring(point.get('x'), animConfig));
+ pointsMap = pointsMap.set(`y${index + i}`, spring(point.get('y'), animConfig));
+ });
+
+ this.setState({ pointsMap });
+ }
+
+}
+
+reactMixin.onClass(EdgeContainer, PureRenderMixin);
diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js
index 9d462b3fd..796178dfe 100644
--- a/client/app/scripts/charts/edge.js
+++ b/client/app/scripts/charts/edge.js
@@ -1,102 +1,45 @@
-import _ from 'lodash';
-import d3 from 'd3';
import React from 'react';
-import { Motion, spring } from 'react-motion';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
import { enterEdge, leaveEdge } from '../actions/app-actions';
-const line = d3.svg.line()
- .interpolate('basis')
- .x(d => d.x)
- .y(d => d.y);
-
-const animConfig = [80, 20]; // stiffness, damping
-
-const flattenPoints = points => {
- const flattened = {};
- points.forEach((point, i) => {
- flattened[`x${i}`] = spring(point.x, animConfig);
- flattened[`y${i}`] = spring(point.y, animConfig);
- });
- return flattened;
-};
-
-const extractPoints = points => {
- const extracted = [];
- _.each(points, (value, key) => {
- const axis = key[0];
- const index = key.slice(1);
- if (!extracted[index]) {
- extracted[index] = {};
- }
- extracted[index][axis] = value;
- });
- return extracted;
-};
-
export default class Edge extends React.Component {
+
constructor(props, context) {
super(props, context);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
-
- this.state = {
- points: []
- };
- }
-
- componentWillMount() {
- this.ensureSameLength(this.props.points);
- }
-
- componentWillReceiveProps(nextProps) {
- this.ensureSameLength(nextProps.points);
}
render() {
- const classNames = ['edge'];
- const points = flattenPoints(this.props.points);
- const props = this.props;
- const handleMouseEnter = this.handleMouseEnter;
- const handleMouseLeave = this.handleMouseLeave;
+ const { hasSelectedNode, highlightedEdgeIds, id, layoutPrecision,
+ path, selectedNodeId, source, target } = this.props;
- if (this.props.highlighted) {
+ const classNames = ['edge'];
+ if (highlightedEdgeIds.has(id)) {
classNames.push('highlighted');
}
- if (this.props.blurred) {
+ if (hasSelectedNode
+ && source !== selectedNodeId
+ && target !== selectedNodeId) {
classNames.push('blurred');
}
+ if (hasSelectedNode && layoutPrecision === 0
+ && (source === selectedNodeId || target === selectedNodeId)) {
+ classNames.push('focused');
+ }
const classes = classNames.join(' ');
return (
-
- {(interpolated) => {
- const path = line(extractPoints(interpolated));
- return (
-
-
-
-
- );
- }}
-
+
+
+
+
);
}
- ensureSameLength(points) {
- // Spring needs constant list length, hoping that dagre will insert never more than 30
- const length = 30;
- let missing = length - points.length;
-
- while (missing > 0) {
- points.unshift(points[0]);
- missing = length - points.length;
- }
-
- return points;
- }
-
handleMouseEnter(ev) {
enterEdge(ev.currentTarget.id);
}
@@ -105,3 +48,5 @@ export default class Edge extends React.Component {
leaveEdge(ev.currentTarget.id);
}
}
+
+reactMixin.onClass(Edge, PureRenderMixin);
diff --git a/client/app/scripts/charts/node-container.js b/client/app/scripts/charts/node-container.js
new file mode 100644
index 000000000..2296731ae
--- /dev/null
+++ b/client/app/scripts/charts/node-container.js
@@ -0,0 +1,33 @@
+import _ from 'lodash';
+import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
+import d3 from 'd3';
+import { Motion, spring } from 'react-motion';
+
+import Node from './node';
+
+export default class NodeContainer extends React.Component {
+ render() {
+ const { dx, dy, focused, layoutPrecision, zoomScale } = this.props;
+ const animConfig = [80, 20]; // stiffness, damping
+ const scaleFactor = focused ? (2 / zoomScale) : 1;
+ const other = _.omit(this.props, 'dx', 'dy');
+
+ return (
+
+ {interpolated => {
+ const transform = `translate(${d3.round(interpolated.x, layoutPrecision)},`
+ + `${d3.round(interpolated.y, layoutPrecision)})`;
+ return ;
+ }}
+
+ );
+ }
+}
+
+reactMixin.onClass(NodeContainer, PureRenderMixin);
diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js
index 927a05725..ceeb8e899 100644
--- a/client/app/scripts/charts/node.js
+++ b/client/app/scripts/charts/node.js
@@ -1,6 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom';
-import { Motion, spring } from 'react-motion';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
+import classNames from 'classnames';
import { clickNode, enterNode, leaveNode } from '../actions/app-actions';
import { getNodeColor } from '../utils/color-utils';
@@ -33,7 +35,18 @@ function getNodeShape({shape, stack}) {
return stack ? stackedShape(nodeShape) : nodeShape;
}
+function ellipsis(text, fontSize, maxWidth) {
+ const averageCharLength = fontSize / 1.5;
+ const allowedChars = maxWidth / averageCharLength;
+ let truncatedText = text;
+ if (text && text.length > allowedChars) {
+ truncatedText = `${text.slice(0, allowedChars)}...`;
+ }
+ return truncatedText;
+}
+
export default class Node extends React.Component {
+
constructor(props, context) {
super(props, context);
this.handleMouseClick = this.handleMouseClick.bind(this);
@@ -42,95 +55,54 @@ export default class Node extends React.Component {
}
render() {
- const props = this.props;
- const nodeScale = props.focused ? props.selectedNodeScale : props.nodeScale;
- const zoomScale = this.props.zoomScale;
- let scaleFactor = 1;
- if (props.focused) {
- scaleFactor = 1.25 / zoomScale;
- } else if (props.blurred) {
- scaleFactor = 0.75;
- }
+ const { blurred, focused, highlighted, label, nodeScale, pseudo, rank,
+ subLabel, scaleFactor, transform, zoomScale } = this.props;
+
+ const color = getNodeColor(rank, label, pseudo);
+ const labelText = ellipsis(label, 14, nodeScale(4 * scaleFactor));
+ const subLabelText = ellipsis(subLabel, 12, nodeScale(4 * scaleFactor));
+
let labelOffsetY = 18;
let subLabelOffsetY = 35;
- const color = getNodeColor(this.props.rank, this.props.label,
- this.props.pseudo);
- const onMouseEnter = this.handleMouseEnter;
- const onMouseLeave = this.handleMouseLeave;
- const onMouseClick = this.handleMouseClick;
- const classNames = ['node'];
- const animConfig = [80, 20]; // stiffness, damping
- const label = this.ellipsis(props.label, 14, nodeScale(4 * scaleFactor));
- const subLabel = this.ellipsis(props.subLabel, 12, nodeScale(4 * scaleFactor));
let labelFontSize = 14;
let subLabelFontSize = 12;
- if (props.focused) {
+ // render focused nodes in normal size
+ if (focused) {
labelFontSize /= zoomScale;
subLabelFontSize /= zoomScale;
labelOffsetY /= zoomScale;
subLabelOffsetY /= zoomScale;
}
- if (this.props.highlighted) {
- classNames.push('highlighted');
- }
- if (this.props.blurred) {
- classNames.push('blurred');
- }
- if (this.props.pseudo) {
- classNames.push('pseudo');
- }
- const classes = classNames.join(' ');
+ const className = classNames({
+ node: true,
+ highlighted,
+ blurred,
+ pseudo
+ });
const NodeShapeType = getNodeShape(this.props);
return (
-
- {(interpolated) => {
- const transform = `translate(${interpolated.x},${interpolated.y})`;
- return (
-
-
-
- {label}
-
-
- {subLabel}
-
-
- );
- }}
-
+
+
+
+ {labelText}
+
+
+ {subLabelText}
+
+
);
}
- ellipsis(text, fontSize, maxWidth) {
- const averageCharLength = fontSize / 1.5;
- const allowedChars = maxWidth / averageCharLength;
- let truncatedText = text;
- if (text && text.length > allowedChars) {
- truncatedText = `${text.slice(0, allowedChars)}...`;
- }
- return truncatedText;
- }
-
handleMouseClick(ev) {
ev.stopPropagation();
clickNode(this.props.id, this.props.label, ReactDOM.findDOMNode(this).getBoundingClientRect());
@@ -144,3 +116,5 @@ export default class Node extends React.Component {
leaveNode(this.props.id);
}
}
+
+reactMixin.onClass(Node, PureRenderMixin);
diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js
new file mode 100644
index 000000000..86cf69dce
--- /dev/null
+++ b/client/app/scripts/charts/nodes-chart-edges.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
+
+import EdgeContainer from './edge-container';
+
+export default class NodesChartEdges extends React.Component {
+ render() {
+ const {hasSelectedNode, highlightedEdgeIds, layoutEdges, layoutPrecision,
+ selectedNodeId} = this.props;
+
+ return (
+
+ {layoutEdges.toIndexedSeq().map(edge => )}
+
+ );
+ }
+}
+
+reactMixin.onClass(NodesChartEdges, PureRenderMixin);
diff --git a/client/app/scripts/charts/nodes-chart-elements.js b/client/app/scripts/charts/nodes-chart-elements.js
new file mode 100644
index 000000000..d5dca9e39
--- /dev/null
+++ b/client/app/scripts/charts/nodes-chart-elements.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
+
+import NodesChartEdges from './nodes-chart-edges';
+import NodesChartNodes from './nodes-chart-nodes';
+
+export default class NodesChartElements extends React.Component {
+ render() {
+ const props = this.props;
+ return (
+
+
+
+
+ );
+ }
+}
+
+reactMixin.onClass(NodesChartElements, PureRenderMixin);
diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js
new file mode 100644
index 000000000..aa4293c96
--- /dev/null
+++ b/client/app/scripts/charts/nodes-chart-nodes.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
+
+import NodeContainer from './node-container';
+
+export default class NodesChartNodes extends React.Component {
+ render() {
+ const {adjacentNodes, highlightedNodeIds,
+ layoutNodes, layoutPrecision, nodeScale, onNodeClick, scale,
+ selectedMetric, selectedNodeScale, selectedNodeId, topologyId} = this.props;
+
+ const zoomScale = scale;
+
+ // highlighter functions
+ const setHighlighted = node => node.set('highlighted',
+ highlightedNodeIds.has(node.get('id')) || selectedNodeId === node.get('id'));
+ const setFocused = node => node.set('focused', selectedNodeId
+ && (selectedNodeId === node.get('id')
+ || (adjacentNodes && adjacentNodes.includes(node.get('id')))));
+ const setBlurred = node => node.set('blurred', selectedNodeId && !node.get('focused'));
+
+ // make sure blurred nodes are in the background
+ const sortNodes = node => {
+ if (node.get('blurred')) {
+ return 0;
+ }
+ if (node.get('highlighted')) {
+ return 2;
+ }
+ return 1;
+ };
+
+ // TODO: think about pulling this up into the store.
+ const metric = node => (
+ node.get('metrics') && node.get('metrics')
+ .filter(m => m.get('id') === selectedMetric)
+ .first()
+ );
+
+ const nodesToRender = layoutNodes.toIndexedSeq()
+ .map(setHighlighted)
+ .map(setFocused)
+ .map(setBlurred)
+ .sortBy(sortNodes);
+
+ return (
+
+ {nodesToRender.map(node => )}
+
+ );
+ }
+}
+
+reactMixin.onClass(NodesChartNodes, PureRenderMixin);
diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js
index 8260aa17e..04aa6fe80 100644
--- a/client/app/scripts/charts/nodes-chart.js
+++ b/client/app/scripts/charts/nodes-chart.js
@@ -2,18 +2,17 @@ import _ from 'lodash';
import d3 from 'd3';
import debug from 'debug';
import React from 'react';
-import { Map as makeMap } from 'immutable';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
+import { Map as makeMap, fromJS, is as isDeepEqual } from 'immutable';
import timely from 'timely';
-import { DETAILS_PANEL_WIDTH } from '../constants/styles';
import { clickBackground } from '../actions/app-actions';
-import AppStore from '../stores/app-store';
-import Edge from './edge';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
-import { doLayout } from './nodes-layout';
-import Node from './node';
-import NodesError from './nodes-error';
+import { DETAILS_PANEL_WIDTH } from '../constants/styles';
import Logo from '../components/logo';
+import { doLayout } from './nodes-layout';
+import NodesChartElements from './nodes-chart-elements';
const log = debug('scope:nodes-chart');
@@ -29,20 +28,22 @@ const radiusDensity = d3.scale.threshold()
.domain([3, 6]).range([2.5, 3.5, 3]);
export default class NodesChart extends React.Component {
+
constructor(props, context) {
super(props, context);
+
this.handleMouseClick = this.handleMouseClick.bind(this);
this.zoomed = this.zoomed.bind(this);
this.state = {
- nodes: makeMap(),
edges: makeMap(),
- panTranslate: [0, 0],
- scale: 1,
+ nodes: makeMap(),
nodeScale: d3.scale.linear(),
+ panTranslateX: 0,
+ panTranslateY: 0,
+ scale: 1,
selectedNodeScale: d3.scale.linear(),
- hasZoomed: false,
- maxNodesExceeded: false
+ hasZoomed: false
};
}
@@ -51,18 +52,6 @@ export default class NodesChart extends React.Component {
this.setState(state);
}
- componentDidMount() {
- // distinguish pan/zoom from click
- this.isZooming = false;
-
- this.zoom = d3.behavior.zoom()
- .scaleExtent([0.1, 2])
- .on('zoom', this.zoomed);
-
- d3.select('.nodes-chart svg')
- .call(this.zoom);
- }
-
componentWillReceiveProps(nextProps) {
// gather state, setState should be called only once here
const state = _.assign({}, this.state);
@@ -91,9 +80,20 @@ export default class NodesChart extends React.Component {
this.setState(state);
}
+ componentDidMount() {
+ // distinguish pan/zoom from click
+ this.isZooming = false;
+
+ this.zoom = d3.behavior.zoom()
+ .scaleExtent([0.1, 2])
+ .on('zoom', this.zoomed);
+
+ d3.select('.nodes-chart svg')
+ .call(this.zoom);
+ }
+
componentWillUnmount() {
// undoing .call(zoom)
-
d3.select('.nodes-chart svg')
.on('mousedown.zoom', null)
.on('onwheel', null)
@@ -102,170 +102,75 @@ export default class NodesChart extends React.Component {
.on('touchstart.zoom', null);
}
- renderGraphNodes(nodes, nodeScale) {
- const hasSelectedNode = this.props.selectedNodeId
- && this.props.nodes.has(this.props.selectedNodeId);
- const adjacency = hasSelectedNode ? AppStore.getAdjacentNodes(this.props.selectedNodeId) : null;
- const onNodeClick = this.props.onNodeClick;
- const zoomScale = this.state.scale;
- const selectedNodeScale = this.state.selectedNodeScale;
-
- // highlighter functions
- const setHighlighted = node => {
- const highlighted = this.props.highlightedNodeIds.has(node.get('id'))
- || this.props.selectedNodeId === node.get('id');
- return node.set('highlighted', highlighted);
- };
- const setFocused = node => {
- const focused = hasSelectedNode
- && (this.props.selectedNodeId === node.get('id') || adjacency.includes(node.get('id')));
- return node.set('focused', focused);
- };
- const setBlurred = node => node.set('blurred', hasSelectedNode && !node.get('focused'));
-
- // make sure blurred nodes are in the background
- const sortNodes = node => {
- if (node.get('blurred')) {
- return 0;
- }
- if (node.get('highlighted')) {
- return 2;
- }
- return 1;
- };
-
- // TODO: think about pulling this up into the store.
- const metric = node => (
- node.get('metrics') && node.get('metrics')
- .filter(m => m.get('id') === this.props.selectedMetric)
- .first()
- );
-
- return nodes
- .toIndexedSeq()
- .map(setHighlighted)
- .map(setFocused)
- .map(setBlurred)
- .sortBy(sortNodes)
- .map(node => );
- }
-
- renderGraphEdges(edges) {
- const selectedNodeId = this.props.selectedNodeId;
- const hasSelectedNode = selectedNodeId && this.props.nodes.has(selectedNodeId);
-
- const setHighlighted = edge => edge.set('highlighted', this.props.highlightedEdgeIds.has(
- edge.get('id')));
-
- const setBlurred = edge => edge.set('blurred', hasSelectedNode
- && edge.get('source') !== selectedNodeId
- && edge.get('target') !== selectedNodeId);
-
- return edges
- .toIndexedSeq()
- .map(setHighlighted)
- .map(setBlurred)
- .map(edge =>
- );
- }
-
- renderMaxNodesError(show) {
- const errorHint = 'We\u0027re working on it, but for now, try a different view?';
- return (
-
- Too many nodes to show in the browser.
{errorHint}
-
- );
- }
-
- renderEmptyTopologyError(show) {
- return (
-
- Nothing to show. This can have any of these reasons:
-
- - We haven't received any reports from probes recently.
- Are the probes properly configured?
- - There are nodes, but they're currently hidden. Check the view options
- in the bottom-left if they allow for showing hidden nodes.
- - Containers view only: you're not running Docker,
- or you don't have any containers.
-
-
- );
- }
-
render() {
- const nodeElements = this.renderGraphNodes(this.state.nodes, this.state.nodeScale);
- const edgeElements = this.renderGraphEdges(this.state.edges, this.state.nodeScale);
- const scale = this.state.scale;
+ const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state;
- const translate = this.state.panTranslate;
+ // not passing translates into child components for perf reasons, use getTranslate instead
+ const translate = [panTranslateX, panTranslateY];
const transform = `translate(${translate}) scale(${scale})`;
- const svgClassNames = this.state.maxNodesExceeded || nodeElements.size === 0 ? 'hide' : '';
- const errorEmpty = this.renderEmptyTopologyError(AppStore.isTopologyEmpty());
- const errorMaxNodesExceeded = this.renderMaxNodesError(this.state.maxNodesExceeded);
+ const svgClassNames = this.props.isEmpty ? 'hide' : '';
return (
- {errorEmpty}
- {errorMaxNodesExceeded}
);
}
- initNodes(topology) {
+ handleMouseClick() {
+ if (!this.isZooming) {
+ clickBackground();
+ } else {
+ this.isZooming = false;
+ }
+ }
+
+ initNodes(topology, stateNodes) {
+ let nextStateNodes = stateNodes;
+
+ // remove nodes that have disappeared
+ stateNodes.forEach((node, id) => {
+ if (!topology.has(id)) {
+ nextStateNodes = nextStateNodes.delete(id);
+ }
+ });
+
// copy relevant fields to state nodes
- return topology.map((node, id) => makeMap({
- id,
- label: node.get('label'),
- pseudo: node.get('pseudo'),
- metrics: node.get('metrics'),
- subLabel: node.get('label_minor'),
- nodeCount: node.get('node_count'),
- rank: node.get('rank'),
- shape: node.get('shape'),
- stack: node.get('stack'),
- x: 0,
- y: 0
- }));
+ topology.forEach((node, id) => {
+ nextStateNodes = nextStateNodes.mergeIn([id], makeMap({
+ id,
+ label: node.get('label'),
+ pseudo: node.get('pseudo'),
+ subLabel: node.get('label_minor'),
+ nodeCount: node.get('node_count'),
+ metrics: node.get('metrics'),
+ rank: node.get('rank'),
+ shape: node.get('shape'),
+ stack: node.get('stack')
+ }));
+ });
+
+ return nextStateNodes;
}
initEdges(topology, stateNodes) {
@@ -309,10 +214,10 @@ export default class NodesChart extends React.Component {
return {};
}
- const adjacency = AppStore.getAdjacentNodes(props.selectedNodeId);
+ const adjacentNodes = props.adjacentNodes;
const adjacentLayoutNodeIds = [];
- adjacency.forEach(adjacentId => {
+ adjacentNodes.forEach(adjacentId => {
// filter loopback
if (adjacentId !== props.selectedNodeId) {
adjacentLayoutNodeIds.push(adjacentId);
@@ -321,7 +226,7 @@ export default class NodesChart extends React.Component {
// move origin node to center of viewport
const zoomScale = state.scale;
- const translate = state.panTranslate;
+ const translate = [state.panTranslateX, state.panTranslateY];
const centerX = (-translate[0] + (props.width + MARGINS.left
- DETAILS_PANEL_WIDTH) / 2) / zoomScale;
const centerY = (-translate[1] + (props.height + MARGINS.top) / 2) / zoomScale;
@@ -356,10 +261,10 @@ export default class NodesChart extends React.Component {
|| _.includes(adjacentLayoutNodeIds, edge.get('target'))) {
const source = stateNodes.get(edge.get('source'));
const target = stateNodes.get(edge.get('target'));
- return edge.set('points', [
+ return edge.set('points', fromJS([
{x: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')}
- ]);
+ ]));
}
return edge;
});
@@ -374,18 +279,10 @@ export default class NodesChart extends React.Component {
};
}
- handleMouseClick() {
- if (!this.isZooming) {
- clickBackground();
- } else {
- this.isZooming = false;
- }
- }
-
restoreLayout(state) {
// undo any pan/zooming that might have happened
this.zoom.scale(state.scale);
- this.zoom.translate(state.panTranslate);
+ this.zoom.translate([state.panTranslateX, state.panTranslateY]);
const nodes = state.nodes.map(node => node.merge({
x: node.get('px'),
@@ -399,7 +296,7 @@ export default class NodesChart extends React.Component {
return edge;
});
- return { edges, nodes};
+ return { edges, nodes };
}
updateGraphState(props, state) {
@@ -412,9 +309,13 @@ export default class NodesChart extends React.Component {
};
}
- let stateNodes = this.initNodes(props.nodes, state.nodes);
- let stateEdges = this.initEdges(props.nodes, stateNodes);
+ const stateNodes = this.initNodes(props.nodes, state.nodes);
+ const stateEdges = this.initEdges(props.nodes, stateNodes);
+ const nodeMetrics = stateNodes.map(node => makeMap({
+ metrics: node.get('metrics')
+ }));
const nodeScale = this.getNodeScale(props);
+ const nextState = { nodeScale };
const options = {
width: props.width,
@@ -431,21 +332,15 @@ export default class NodesChart extends React.Component {
log(`graph layout took ${timedLayouter.time}ms`);
- // layout was aborted
- if (!graph) {
- return {maxNodesExceeded: true};
- }
- stateNodes = graph.nodes.mergeDeep(stateNodes.map(node => makeMap({
- metrics: node.get('metrics')
- })));
- stateEdges = graph.edges;
-
- // save coordinates for restore
- stateNodes = stateNodes.map(node => node.merge({
- px: node.get('x'),
- py: node.get('y')
- }));
- stateEdges = stateEdges.map(edge => edge.set('ppoints', edge.get('points')));
+ // inject metrics and save coordinates for restore
+ const layoutNodes = graph.nodes
+ .mergeDeep(nodeMetrics)
+ .map(node => node.merge({
+ px: node.get('x'),
+ py: node.get('y')
+ }));
+ const layoutEdges = graph.edges
+ .map(edge => edge.set('ppoints', edge.get('points')));
// adjust layout based on viewport
const xFactor = (props.width - MARGINS.left - MARGINS.right) / graph.width;
@@ -453,19 +348,21 @@ export default class NodesChart extends React.Component {
const zoomFactor = Math.min(xFactor, yFactor);
let zoomScale = this.state.scale;
- if (this.zoom && !this.state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
+ if (!this.props.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
zoomScale = zoomFactor;
// saving in d3's behavior cache
this.zoom.scale(zoomFactor);
}
- return {
- nodes: stateNodes,
- edges: stateEdges,
- scale: zoomScale,
- nodeScale,
- maxNodesExceeded: false
- };
+ nextState.scale = zoomScale;
+ if (!isDeepEqual(stateNodes, state.nodes)) {
+ nextState.nodes = layoutNodes;
+ }
+ if (!isDeepEqual(stateEdges, state.edges)) {
+ nextState.edges = layoutEdges;
+ }
+
+ return nextState;
}
getNodeScale(props) {
@@ -483,9 +380,12 @@ export default class NodesChart extends React.Component {
if (!this.props.selectedNodeId) {
this.setState({
hasZoomed: true,
- panTranslate: d3.event.translate.slice(),
+ panTranslateX: d3.event.translate[0],
+ panTranslateY: d3.event.translate[1],
scale: d3.event.scale
});
}
}
}
+
+reactMixin.onClass(NodesChart, PureRenderMixin);
diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js
index 9707f1c66..c54c65bbe 100644
--- a/client/app/scripts/charts/nodes-layout.js
+++ b/client/app/scripts/charts/nodes-layout.js
@@ -1,13 +1,12 @@
import dagre from 'dagre';
import debug from 'debug';
-import { Map as makeMap, Set as ImmSet } from 'immutable';
+import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { updateNodeDegrees } from '../utils/topology-utils';
const log = debug('scope:nodes-layout');
-const MAX_NODES = 100;
const topologyCaches = {};
const DEFAULT_WIDTH = 800;
const DEFAULT_MARGINS = {top: 0, left: 0};
@@ -51,11 +50,6 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) {
let nodes = imNodes;
let edges = imEdges;
- if (nodes.size > MAX_NODES) {
- log(`Too many nodes for graph layout engine. Limit: ${MAX_NODES}`);
- return null;
- }
-
const options = opts || {};
const scale = options.scale || DEFAULT_SCALE;
const ranksep = scale(RANK_SEPARATION_FACTOR);
@@ -122,13 +116,13 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) {
graph.edges().forEach(graphEdge => {
const graphEdgeMeta = graph.edge(graphEdge);
const edge = edges.get(graphEdgeMeta.id);
- const points = graphEdgeMeta.points;
+ let points = fromJS(graphEdgeMeta.points);
// set beginning and end points to node coordinates to ignore node bounding box
const source = nodes.get(fromGraphNodeId(edge.get('source')));
const target = nodes.get(fromGraphNodeId(edge.get('target')));
- points[0] = {x: source.get('x'), y: source.get('y')};
- points[points.length - 1] = {x: target.get('x'), y: target.get('y')};
+ points = points.mergeIn([0], {x: source.get('x'), y: source.get('y')});
+ points = points.mergeIn([points.size - 1], {x: target.get('x'), y: target.get('y')});
edges = edges.setIn([graphEdgeMeta.id, 'points'], points);
});
@@ -251,13 +245,12 @@ function shiftLayoutToCenter(layout, opts) {
y: node.get('y') + offsetY
}));
- result.edges = layout.edges.map(edge => {
- const points = edge.get('points').map(point => ({
- x: point.x + offsetX,
- y: point.y + offsetY
- }));
- return edge.set('points', points);
- });
+ result.edges = layout.edges.map(edge => edge.update('points',
+ points => points.map(point => point.merge({
+ x: point.get('x') + offsetX,
+ y: point.get('y') + offsetY
+ }))
+ ));
return result;
}
@@ -271,10 +264,10 @@ function shiftLayoutToCenter(layout, opts) {
function setSimpleEdgePoints(edge, nodeCache) {
const source = nodeCache.get(edge.get('source'));
const target = nodeCache.get(edge.get('target'));
- return edge.set('points', [
+ return edge.set('points', fromJS([
{x: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')}
- ]);
+ ]));
}
/**
@@ -300,14 +293,14 @@ export function hasUnseenNodes(nodes, cache) {
*/
function hasSameEndpoints(cachedEdge, nodes) {
const oldPoints = cachedEdge.get('points');
- const oldSourcePoint = oldPoints[0];
- const oldTargetPoint = oldPoints[oldPoints.length - 1];
+ const oldSourcePoint = oldPoints.first();
+ const oldTargetPoint = oldPoints.last();
const newSource = nodes.get(cachedEdge.get('source'));
const newTarget = nodes.get(cachedEdge.get('target'));
- return (oldSourcePoint.x === newSource.get('x')
- && oldSourcePoint.y === newSource.get('y')
- && oldTargetPoint.x === newTarget.get('x')
- && oldTargetPoint.y === newTarget.get('y'));
+ return (oldSourcePoint.get('x') === newSource.get('x')
+ && oldSourcePoint.get('y') === newSource.get('y')
+ && oldTargetPoint.get('x') === newTarget.get('x')
+ && oldTargetPoint.get('y') === newTarget.get('y'));
}
/**
diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js
index c759948d4..2868de368 100644
--- a/client/app/scripts/components/app.js
+++ b/client/app/scripts/components/app.js
@@ -1,5 +1,7 @@
-import React from 'react';
import debug from 'debug';
+import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
import Logo from './logo';
import AppStore from '../stores/app-store';
@@ -26,9 +28,11 @@ const RIGHT_ANGLE_KEY_IDENTIFIER = 'U+003C';
const LEFT_ANGLE_KEY_IDENTIFIER = 'U+003E';
const keyPressLog = debug('scope:app-key-press');
+/* make sure these can all be shallow-checked for equality for PureRenderMixin */
function getStateFromStores() {
return {
activeTopologyOptions: AppStore.getActiveTopologyOptions(),
+ adjacentNodes: AppStore.getAdjacentNodes(AppStore.getSelectedNodeId()),
controlStatus: AppStore.getControlStatus(),
controlPipe: AppStore.getControlPipe(),
currentTopology: AppStore.getCurrentTopology(),
@@ -47,6 +51,7 @@ function getStateFromStores() {
selectedMetric: AppStore.getSelectedMetric(),
topologies: AppStore.getTopologies(),
topologiesLoaded: AppStore.isTopologiesLoaded(),
+ topologyEmpty: AppStore.isTopologyEmpty(),
updatePaused: AppStore.isUpdatePaused(),
updatePausedAt: AppStore.getUpdatePausedAt(),
version: AppStore.getVersion(),
@@ -136,6 +141,8 @@ export default class App extends React.Component {
selectedMetric={this.state.selectedMetric}
forceRelayout={this.state.forceRelayout}
topologyOptions={this.state.activeTopologyOptions}
+ topologyEmpty={this.state.topologyEmpty}
+ adjacentNodes={this.state.adjacentNodes}
topologyId={this.state.currentTopologyId} />
@@ -157,3 +164,5 @@ export default class App extends React.Component {
);
}
}
+
+reactMixin.onClass(App, PureRenderMixin);
diff --git a/client/app/scripts/components/debug-toolbar.js b/client/app/scripts/components/debug-toolbar.js
index e5e5e95de..1549c3c7d 100644
--- a/client/app/scripts/components/debug-toolbar.js
+++ b/client/app/scripts/components/debug-toolbar.js
@@ -1,6 +1,7 @@
/* eslint react/jsx-no-bind: "off" */
import React from 'react';
import _ from 'lodash';
+import Perf from 'react-addons-perf';
import debug from 'debug';
const log = debug('scope:debug-panel');
@@ -31,7 +32,7 @@ const LABEL_PREFIXES = _.range('A'.charCodeAt(), 'Z'.charCodeAt() + 1)
.map(n => String.fromCharCode(n));
-const randomLetter = () => _.sample(LABEL_PREFIXES);
+// const randomLetter = () => _.sample(LABEL_PREFIXES);
const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCount = 1) => ({
@@ -91,11 +92,25 @@ function addAllMetricVariants() {
}
+function stopPerf() {
+ Perf.stop();
+ const measurements = Perf.getLastMeasurements();
+ Perf.printInclusive(measurements);
+ Perf.printWasted(measurements);
+}
+
+function startPerf(delay) {
+ Perf.start();
+ setTimeout(stopPerf, delay * 1000);
+}
+
+
function addNodes(n) {
const ns = AppStore.getNodes();
const nodeNames = ns.keySeq().toJS();
- const newNodeNames = _.range(ns.size, ns.size + n).map(() => (
- `${randomLetter()}${randomLetter()}-zing`
+ const newNodeNames = _.range(ns.size, ns.size + n).map(i => (
+ // `${randomLetter()}${randomLetter()}-zing`
+ `zing${i}`
));
const allNodes = _(nodeNames).concat(newNodeNames).value();
@@ -110,9 +125,9 @@ function addNodes(n) {
});
}
-
export function showingDebugToolbar() {
- return 'debugToolbar' in localStorage && JSON.parse(localStorage.debugToolbar);
+ return (('debugToolbar' in localStorage && JSON.parse(localStorage.debugToolbar))
+ || location.pathname.indexOf('debug') > -1);
}
@@ -196,6 +211,13 @@ export class DebugToolbar extends React.Component {
))}
+
+
+
+
+
+
+
);
}
diff --git a/client/app/scripts/components/details-card.js b/client/app/scripts/components/details-card.js
index b1f8694a0..bfee43e9a 100644
--- a/client/app/scripts/components/details-card.js
+++ b/client/app/scripts/components/details-card.js
@@ -1,4 +1,6 @@
import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
import NodeDetails from './node-details';
import { DETAILS_PANEL_WIDTH as WIDTH, DETAILS_PANEL_OFFSET as OFFSET,
@@ -51,3 +53,5 @@ export default class DetailsCard extends React.Component {
);
}
}
+
+reactMixin.onClass(DetailsCard, PureRenderMixin);
diff --git a/client/app/scripts/components/details.js b/client/app/scripts/components/details.js
index 17aa4a09a..528e2790b 100644
--- a/client/app/scripts/components/details.js
+++ b/client/app/scripts/components/details.js
@@ -8,7 +8,7 @@ export default function Details({controlStatus, details, nodes}) {
{details.toIndexedSeq().map((obj, index) =>
+ nodeControlStatus={controlStatus.get(obj.id)} {...obj} />
)}
);
diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js
index 6fc44676d..075761532 100644
--- a/client/app/scripts/components/node-details.js
+++ b/client/app/scripts/components/node-details.js
@@ -135,7 +135,8 @@ export default class NodeDetails extends React.Component {
const details = this.props.details;
const showControls = details.controls && details.controls.length > 0;
const nodeColor = getNodeColorDark(details.rank, details.label, details.pseudo);
- const {error, pending} = (this.props.nodeControlStatus || {});
+ const {error, pending} = this.props.nodeControlStatus
+ ? this.props.nodeControlStatus.toJS() : {};
const tools = this.renderTools();
const styles = {
controls: {
diff --git a/client/app/scripts/components/node-details/node-details-relatives-link.js b/client/app/scripts/components/node-details/node-details-relatives-link.js
index 315f7d026..50a19b24f 100644
--- a/client/app/scripts/components/node-details/node-details-relatives-link.js
+++ b/client/app/scripts/components/node-details/node-details-relatives-link.js
@@ -1,5 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
import { clickRelative } from '../../actions/app-actions';
@@ -25,3 +27,5 @@ export default class NodeDetailsRelativesLink extends React.Component {
);
}
}
+
+reactMixin.onClass(NodeDetailsRelativesLink, PureRenderMixin);
diff --git a/client/app/scripts/components/node-details/node-details-table-node-link.js b/client/app/scripts/components/node-details/node-details-table-node-link.js
index ac295f12d..001f549fc 100644
--- a/client/app/scripts/components/node-details/node-details-table-node-link.js
+++ b/client/app/scripts/components/node-details/node-details-table-node-link.js
@@ -1,5 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
import { clickRelative } from '../../actions/app-actions';
@@ -32,3 +34,5 @@ export default class NodeDetailsTableNodeLink extends React.Component {
);
}
}
+
+reactMixin.onClass(NodeDetailsTableNodeLink, PureRenderMixin);
diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js
index ed2f29531..28d3ae79d 100644
--- a/client/app/scripts/components/nodes.js
+++ b/client/app/scripts/components/nodes.js
@@ -1,10 +1,31 @@
import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
import NodesChart from '../charts/nodes-chart';
+import NodesError from '../charts/nodes-error';
const navbarHeight = 160;
const marginTop = 0;
+/**
+ * dynamic coords precision based on topology size
+ */
+function getLayoutPrecision(nodesCount) {
+ let precision;
+ if (nodesCount >= 50) {
+ precision = 0;
+ } else if (nodesCount > 20) {
+ precision = 1;
+ } else if (nodesCount > 10) {
+ precision = 2;
+ } else {
+ precision = 3;
+ }
+
+ return precision;
+}
+
export default class Nodes extends React.Component {
constructor(props, context) {
super(props, context);
@@ -24,8 +45,37 @@ export default class Nodes extends React.Component {
window.removeEventListener('resize', this.handleResize);
}
+ renderEmptyTopologyError(show) {
+ return (
+
+ Nothing to show. This can have any of these reasons:
+
+ - We haven't received any reports from probes recently.
+ Are the probes properly configured?
+ - There are nodes, but they're currently hidden. Check the view options
+ in the bottom-left if they allow for showing hidden nodes.
+ - Containers view only: you're not running Docker,
+ or you don't have any containers.
+
+
+ );
+ }
+
render() {
- return ;
+ const { nodes, selectedNodeId, topologyEmpty } = this.props;
+ const layoutPrecision = getLayoutPrecision(nodes.size);
+ const hasSelectedNode = selectedNodeId && nodes.has(selectedNodeId);
+ const errorEmpty = this.renderEmptyTopologyError(topologyEmpty);
+
+ return (
+
+ {topologyEmpty && errorEmpty}
+
+
+ );
}
handleResize() {
@@ -39,3 +89,5 @@ export default class Nodes extends React.Component {
this.setState({height, width});
}
}
+
+reactMixin.onClass(Nodes, PureRenderMixin);
diff --git a/client/app/scripts/components/show-more.js b/client/app/scripts/components/show-more.js
index a8ed87ec0..a9d17bff6 100644
--- a/client/app/scripts/components/show-more.js
+++ b/client/app/scripts/components/show-more.js
@@ -1,4 +1,6 @@
import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
export default class ShowMore extends React.Component {
@@ -28,3 +30,5 @@ export default class ShowMore extends React.Component {
);
}
}
+
+reactMixin.onClass(ShowMore, PureRenderMixin);
diff --git a/client/app/scripts/components/topologies.js b/client/app/scripts/components/topologies.js
index 2cc785558..d8152f412 100644
--- a/client/app/scripts/components/topologies.js
+++ b/client/app/scripts/components/topologies.js
@@ -1,4 +1,6 @@
import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
import { clickTopology } from '../actions/app-actions';
@@ -69,3 +71,5 @@ export default class Topologies extends React.Component {
);
}
}
+
+reactMixin.onClass(Topologies, PureRenderMixin);
diff --git a/client/app/scripts/components/topology-option-action.js b/client/app/scripts/components/topology-option-action.js
index 65196d5c5..b83eeabe8 100644
--- a/client/app/scripts/components/topology-option-action.js
+++ b/client/app/scripts/components/topology-option-action.js
@@ -1,4 +1,6 @@
import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
import { changeTopologyOption } from '../actions/app-actions';
@@ -26,3 +28,5 @@ export default class TopologyOptionAction extends React.Component {
);
}
}
+
+reactMixin.onClass(TopologyOptionAction, PureRenderMixin);
diff --git a/client/app/scripts/components/topology-options.js b/client/app/scripts/components/topology-options.js
index 31f02e583..758cda37a 100644
--- a/client/app/scripts/components/topology-options.js
+++ b/client/app/scripts/components/topology-options.js
@@ -1,4 +1,6 @@
import React from 'react';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import reactMixin from 'react-mixin';
import TopologyOptionAction from './topology-option-action';
@@ -29,3 +31,5 @@ export default class TopologyOptions extends React.Component {
);
}
}
+
+reactMixin.onClass(TopologyOptions, PureRenderMixin);
diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js
index 31e99bbb1..40690b065 100644
--- a/client/app/scripts/stores/app-store.js
+++ b/client/app/scripts/stores/app-store.js
@@ -142,9 +142,10 @@ export class AppStore extends Store {
// keep at the top
getAppState() {
+ const cp = this.getControlPipe();
return {
- controlPipe: this.getControlPipe(),
- nodeDetails: this.getNodeDetailsState(),
+ controlPipe: cp ? cp.toJS() : null,
+ nodeDetails: this.getNodeDetailsState().toJS(),
selectedNodeId,
pinnedMetricType,
topologyId: currentTopologyId,
@@ -190,12 +191,11 @@ export class AppStore extends Store {
}
getControlStatus() {
- return controlStatus.toJS();
+ return controlStatus;
}
getControlPipe() {
- const cp = controlPipes.last();
- return cp && cp.toJS();
+ return controlPipes.last();
}
getCurrentTopology() {
@@ -240,7 +240,7 @@ export class AppStore extends Store {
getNodeDetailsState() {
return nodeDetails.toIndexedSeq().map(details => ({
id: details.id, label: details.label, topologyId: details.topologyId
- })).toJS();
+ }));
}
getTopCardNodeId() {
diff --git a/client/app/styles/main.less b/client/app/styles/main.less
index eb2162b1f..b43723399 100644
--- a/client/app/styles/main.less
+++ b/client/app/styles/main.less
@@ -333,7 +333,7 @@ h2 {
}
}
- .nodes > .node {
+ .nodes-chart-nodes > .node {
cursor: pointer;
transition: opacity .5s @base-ease;
@@ -370,6 +370,10 @@ h2 {
opacity: @edge-opacity-blurred;
}
+ &.focused {
+ animation: focusing 1.5s ease-in-out;
+ }
+
.link {
stroke: @text-secondary-color;
stroke-width: @edge-link-stroke-width;
@@ -1007,7 +1011,7 @@ h2 {
&:last-child {
margin-bottom: 0;
}
-
+
.fa {
margin-left: 4px;
color: darkred;
@@ -1058,6 +1062,16 @@ h2 {
font-size: .7rem;
}
+@keyframes focusing {
+ 0% {
+ opacity: 0;
+ } 33% {
+ opacity: 0.2;
+ } 100% {
+ opacity: 1;
+ }
+}
+
@keyframes blinking {
0%, 100% {
opacity: 1.0;
diff --git a/client/build/debug.html b/client/build/debug.html
new file mode 100644
index 000000000..e4431f72d
--- /dev/null
+++ b/client/build/debug.html
@@ -0,0 +1,20 @@
+
+
+
+
+ Weave Scope
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/package.json b/client/package.json
index 746925294..51d0875cf 100644
--- a/client/package.json
+++ b/client/package.json
@@ -25,6 +25,7 @@
"react-addons-update": "^0.14.7",
"react-dom": "^0.14.7",
"react-motion": "0.3.1",
+ "react-mixin": "^3.0.3",
"reqwest": "~2.0.5",
"timely": "0.1.0"
},
@@ -50,14 +51,17 @@
"less": "~2.6.1",
"less-loader": "2.2.2",
"postcss-loader": "0.8.2",
+ "react-addons-perf": "^0.14.0",
"style-loader": "0.13.0",
"url": "0.11.0",
"url-loader": "0.5.7",
"webpack": "~1.12.4"
},
"optionalDependencies": {
+ "browser-perf": "^1.4.5",
"express": "~4.13.3",
"http-proxy": "^1.12.0",
+ "perfjankie": "^2.1.0",
"react-hot-loader": "~1.3.0",
"webpack-dev-server": "~1.14.1"
},
diff --git a/client/test/actions/90-nodes-select.js b/client/test/actions/90-nodes-select.js
new file mode 100644
index 000000000..ed7c28313
--- /dev/null
+++ b/client/test/actions/90-nodes-select.js
@@ -0,0 +1,121 @@
+/* eslint-disable */
+
+var fs = require('fs');
+var debug = require('debug')('scope:test:action:90-nodes-select');
+
+function clickIfVisible(list, index) {
+ var el = list[index++];
+ el.isDisplayed(function(err, visible) {
+ if (err) {
+ debug(err);
+ } else if (visible) {
+ el.click();
+ } else {
+ if (index < list.length) {
+ clickIfVisible(list, index);
+ }
+ }
+ });
+}
+
+module.exports = function(cfg) {
+
+ var startUrl = 'http://' + cfg.host + '/debug.html';
+ var selectedUrl = 'http://' + cfg.host + '/debug.html#!/state/{"nodeDetails":[{"id":"zing11","label":"zing11","topologyId":"containers"}],"selectedNodeId":"zing11","topologyId":"containers","topologyOptions":{"processes":{"unconnected":"hide"},"processes-by-name":{"unconnected":"hide"},"containers":{"system":"hide","stopped":"hide"},"containers-by-hostname":{"system":"hide","stopped":"hide"},"containers-by-image":{"system":"hide","stopped":"hide"}}}';
+
+ // cfg - The configuration object. args, from the example above.
+ return function(browser) {
+ // browser is created using wd.promiseRemote()
+ // More info about wd at https://github.com/admc/wd
+ return browser.get('http://' + cfg.host + '/debug.html')
+ .then(function() {
+ debug('starting run ' + cfg.run);
+ return browser.sleep(2000);
+ })
+ .then(function() {
+ return browser.elementByCssSelector('.debug-panel button:nth-child(5)');
+ // return browser.elementByCssSelector('.debug-panel div:nth-child(2) button:nth-child(9)');
+ })
+ .then(function(el) {
+ debug('debug-panel found');
+ return el.click(function() {
+ el.click(function() {
+ el.click();
+ });
+ });
+ })
+ .then(function() {
+ return browser.sleep(2000);
+ })
+
+ .then(function() {
+ return browser.sleep(2000);
+ })
+ .then(function() {
+ debug('select node');
+ return browser.get(selectedUrl);
+ })
+ .then(function() {
+ return browser.sleep(5000);
+ })
+ .then(function() {
+ debug('deselect node');
+ return browser.elementByCssSelector('.fa-close', function(err, el) {
+ return el.click();
+ });
+ })
+
+ .then(function() {
+ return browser.sleep(2000);
+ })
+ .then(function() {
+ debug('select node');
+ return browser.get(selectedUrl);
+ })
+ .then(function() {
+ return browser.sleep(5000);
+ })
+ .then(function() {
+ debug('deselect node');
+ return browser.elementByCssSelector('.fa-close', function(err, el) {
+ return el.click();
+ });
+ })
+
+ .then(function() {
+ return browser.sleep(2000);
+ })
+ .then(function() {
+ debug('select node');
+ return browser.get(selectedUrl);
+ })
+ .then(function() {
+ return browser.sleep(5000);
+ })
+ .then(function() {
+ debug('deselect node');
+ return browser.elementByCssSelector('.fa-close', function(err, el) {
+ return el.click();
+ });
+ })
+
+ .then(function() {
+ return browser.sleep(2000, function() {
+ debug('scenario done');
+ });
+ })
+ .fail(function(err) {
+ debug('exception. taking screenshot', err);
+ browser.takeScreenshot(function(err, data) {
+ if (err) {
+ debug(err);
+ } else {
+ var base64Data = data.replace(/^data:image\/png;base64,/,"");
+ fs.writeFile('90-nodes-select-' + cfg.run + '.png', base64Data, 'base64', function(err) {
+ if(err) debug(err);
+ });
+ }
+ });
+ });
+ }
+};
diff --git a/client/test/browser-perf/main.js b/client/test/browser-perf/main.js
new file mode 100644
index 000000000..039c6d3ad
--- /dev/null
+++ b/client/test/browser-perf/main.js
@@ -0,0 +1,9 @@
+var browserPerf = require('browser-perf');
+var options = {
+ selenium: 'http://local.docker:4444/wd/hub',
+ actions: [require('./custom-action.js')()]
+}
+browserPerf('http://local.docker:4040/debug.html', function(err, res){
+ console.error(err);
+ console.log(res);
+}, options);
diff --git a/client/test/perfjankie/main.js b/client/test/perfjankie/main.js
new file mode 100644
index 000000000..6d4ef7e9c
--- /dev/null
+++ b/client/test/perfjankie/main.js
@@ -0,0 +1,55 @@
+var perfjankie = require('perfjankie');
+
+var run = process.env.COMMIT || 'commit#Hash'; // A hash for the commit, displayed in the x-axis in the dashboard
+var time = process.env.DATE || new Date().getTime() // Used to sort the data when displaying graph. Can be the time when a commit was made
+var scenario = process.env.ACTIONS || '90-nodes-select';
+var host = process.env.HOST || 'localhost:4040';
+var actions = require('../actions/' + scenario)({host: host, run: run});
+
+perfjankie({
+ /* The next set of values identify the test */
+ suite: 'Scope',
+ name: scenario, // A friendly name for the URL. This is shown as component name in the dashboard
+ time: time,
+ run: run,
+ repeat: 10, // Run the tests 10 times. Default is 1 time
+
+ /* Identifies where the data and the dashboard are saved */
+ couch: {
+ server: 'http://local.docker:5984',
+ database: 'performance'
+ // updateSite: !process.env.CI, // If true, updates the couchApp that shows the dashboard. Set to false in when running Continuous integration, run this the first time using command line.
+ // onlyUpdateSite: false // No data to upload, just update the site. Recommended to do from dev box as couchDB instance may require special access to create views.
+ },
+
+ callback: function(err, res) {
+ if (err)
+ console.log(err);
+ // The callback function, err is falsy if all of the following happen
+ // 1. Browsers perf tests ran
+ // 2. Data has been saved in couchDB
+ // err is not falsy even if update site fails.
+ },
+
+ /* OPTIONS PASSED TO BROWSER-PERF */
+ // Properties identifying the test environment */
+ browsers: [{ // This can also be a ['chrome', 'firefox'] or 'chrome,firefox'
+ browserName: 'chrome',
+ chromeOptions: {
+ perfLoggingPrefs: {
+ 'traceCategories': 'toplevel,disabled-by-default-devtools.timeline.frame,blink.console,disabled-by-default-devtools.timeline,benchmark'
+ },
+ args: ['--enable-gpu-benchmarking', '--enable-thread-composting']
+ },
+ loggingPrefs: {
+ performance: 'ALL'
+ }
+ }], // See browser perf browser configuration for all options.
+ actions: actions,
+
+ selenium: {
+ hostname: 'local.docker', // or localhost or hub.browserstack.com
+ port: 4444,
+ }
+
+});
diff --git a/client/test/run-jankie.sh b/client/test/run-jankie.sh
new file mode 100755
index 000000000..2c3f16b2c
--- /dev/null
+++ b/client/test/run-jankie.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+# run jankie on one commit
+
+# These need to be running:
+#
+# docker run --net=host -d -p 4444:4444 -v /dev/shm:/dev/shm selenium/standalone-chrome:2.52.0
+# docker run -d -p 5984:5984 --name couchdb klaemo/couchdb
+#
+# Initialize the results DB
+#
+# perfjankie --only-update-site --couch-server http://local.docker:5984 --couch-database performance
+#
+# Usage:
+#
+# ./run-jankie.sh 192.168.64.3:4040
+#
+# View results: http://local.docker:5984/performance/_design/site/index.html
+#
+
+set -x
+set -e
+
+HOST="$1"
+DATE=$(git log --format="%at" -1)
+COMMIT=$(git log --format="%h" -1)
+
+echo "Testing $COMMIT on $DATE"
+
+../../scope stop
+make SUDO= -C ../..
+../../scope launch
+sleep 5
+
+COMMIT="$COMMIT" DATE=$DATE HOST=$HOST DEBUG=scope* node ./perfjankie/main.js