Merge pull request #1186 from weaveworks/pure-mixin

Performance improvements for canvas
This commit is contained in:
David
2016-04-06 14:23:04 +02:00
33 changed files with 847 additions and 404 deletions

View File

@@ -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') 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 CODECGEN_TARGETS=report/report.codecgen.go render/render.codecgen.go render/detailed/detailed.codecgen.go
RM=--rm RM=--rm
RUN_FLAGS=-ti RUN_FLAGS=-i
BUILD_IN_CONTAINER=true BUILD_IN_CONTAINER=true
GO_ENV=GOGC=off GO_ENV=GOGC=off
GO=env $(GO_ENV) go GO=env $(GO_ENV) go

View File

@@ -1 +1,2 @@
app/scripts/vendor/term.js app/scripts/vendor/term.js
test/

View File

@@ -9,6 +9,7 @@
"comma-dangle": 0, "comma-dangle": 0,
"object-curly-spacing": 0, "object-curly-spacing": 0,
"react/jsx-closing-bracket-location": 0, "react/jsx-closing-bracket-location": 0,
"react/prefer-stateless-function": 0,
"react/sort-comp": 0, "react/sort-comp": 0,
"react/prop-types": 0 "react/prop-types": 0
} }

1
client/.gitignore vendored
View File

@@ -2,3 +2,4 @@ node_modules
build/app.js build/app.js
build/*[woff2?|ttf|eot|svg] build/*[woff2?|ttf|eot|svg]
coverage/ coverage/
test/*png

View File

@@ -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 <Edge {...other} path={path} />;
}
return (
<Motion style={this.state.pointsMap.toJS()}>
{(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 <Edge {...other} path={path} />;
}}
</Motion>
);
}
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);

View File

@@ -1,100 +1,43 @@
import _ from 'lodash';
import d3 from 'd3';
import React from 'react'; 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'; 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 { export default class Edge extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.handleMouseEnter = this.handleMouseEnter.bind(this); this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this); this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.state = {
points: []
};
}
componentWillMount() {
this.ensureSameLength(this.props.points);
}
componentWillReceiveProps(nextProps) {
this.ensureSameLength(nextProps.points);
} }
render() { render() {
const classNames = ['edge']; const { hasSelectedNode, highlightedEdgeIds, id, layoutPrecision,
const points = flattenPoints(this.props.points); path, selectedNodeId, source, target } = this.props;
const props = this.props;
const handleMouseEnter = this.handleMouseEnter;
const handleMouseLeave = this.handleMouseLeave;
if (this.props.highlighted) { const classNames = ['edge'];
if (highlightedEdgeIds.has(id)) {
classNames.push('highlighted'); classNames.push('highlighted');
} }
if (this.props.blurred) { if (hasSelectedNode
&& source !== selectedNodeId
&& target !== selectedNodeId) {
classNames.push('blurred'); classNames.push('blurred');
} }
if (hasSelectedNode && layoutPrecision === 0
&& (source === selectedNodeId || target === selectedNodeId)) {
classNames.push('focused');
}
const classes = classNames.join(' '); const classes = classNames.join(' ');
return ( return (
<Motion style={points}> <g className={classes} onMouseEnter={this.handleMouseEnter}
{(interpolated) => { onMouseLeave={this.handleMouseLeave} id={id}>
const path = line(extractPoints(interpolated));
return (
<g className={classes} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} id={props.id}>
<path d={path} className="shadow" /> <path d={path} className="shadow" />
<path d={path} className="link" /> <path d={path} className="link" />
</g> </g>
); );
}}
</Motion>
);
}
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) { handleMouseEnter(ev) {
@@ -105,3 +48,5 @@ export default class Edge extends React.Component {
leaveEdge(ev.currentTarget.id); leaveEdge(ev.currentTarget.id);
} }
} }
reactMixin.onClass(Edge, PureRenderMixin);

View File

@@ -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 (
<Motion style={{
x: spring(dx, animConfig),
y: spring(dy, animConfig),
f: spring(scaleFactor, animConfig)
}}>
{interpolated => {
const transform = `translate(${d3.round(interpolated.x, layoutPrecision)},`
+ `${d3.round(interpolated.y, layoutPrecision)})`;
return <Node {...other} transform={transform} scaleFactor={interpolated.f} />;
}}
</Motion>
);
}
}
reactMixin.onClass(NodeContainer, PureRenderMixin);

View File

@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; 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 { clickNode, enterNode, leaveNode } from '../actions/app-actions';
import { getNodeColor } from '../utils/color-utils'; import { getNodeColor } from '../utils/color-utils';
@@ -33,7 +35,18 @@ function getNodeShape({shape, stack}) {
return stack ? stackedShape(nodeShape) : nodeShape; 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 { export default class Node extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.handleMouseClick = this.handleMouseClick.bind(this); this.handleMouseClick = this.handleMouseClick.bind(this);
@@ -42,93 +55,52 @@ export default class Node extends React.Component {
} }
render() { render() {
const props = this.props; const { blurred, focused, highlighted, label, nodeScale, pseudo, rank,
const nodeScale = props.focused ? props.selectedNodeScale : props.nodeScale; subLabel, scaleFactor, transform, zoomScale } = this.props;
const zoomScale = this.props.zoomScale;
let scaleFactor = 1; const color = getNodeColor(rank, label, pseudo);
if (props.focused) { const labelText = ellipsis(label, 14, nodeScale(4 * scaleFactor));
scaleFactor = 1.25 / zoomScale; const subLabelText = ellipsis(subLabel, 12, nodeScale(4 * scaleFactor));
} else if (props.blurred) {
scaleFactor = 0.75;
}
let labelOffsetY = 18; let labelOffsetY = 18;
let subLabelOffsetY = 35; 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 labelFontSize = 14;
let subLabelFontSize = 12; let subLabelFontSize = 12;
if (props.focused) { // render focused nodes in normal size
if (focused) {
labelFontSize /= zoomScale; labelFontSize /= zoomScale;
subLabelFontSize /= zoomScale; subLabelFontSize /= zoomScale;
labelOffsetY /= zoomScale; labelOffsetY /= zoomScale;
subLabelOffsetY /= 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); const NodeShapeType = getNodeShape(this.props);
return ( return (
<Motion style={{ <g className={className} transform={transform} onClick={this.handleMouseClick}
x: spring(this.props.dx, animConfig), onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
y: spring(this.props.dy, animConfig),
f: spring(scaleFactor, animConfig),
labelFontSize: spring(labelFontSize, animConfig),
subLabelFontSize: spring(subLabelFontSize, animConfig),
labelOffsetY: spring(labelOffsetY, animConfig),
subLabelOffsetY: spring(subLabelOffsetY, animConfig)
}}>
{(interpolated) => {
const transform = `translate(${interpolated.x},${interpolated.y})`;
return (
<g className={classes} transform={transform} id={props.id}
onClick={onMouseClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<NodeShapeType <NodeShapeType
size={nodeScale(interpolated.f)} size={nodeScale(scaleFactor)}
color={color} color={color}
{...props} /> {...this.props} />
<text className="node-label" textAnchor="middle" <text className="node-label" textAnchor="middle" style={{fontSize: labelFontSize}}
style={{fontSize: interpolated.labelFontSize}} x="0" y={labelOffsetY + nodeScale(0.5 * scaleFactor)}>
x="0" y={interpolated.labelOffsetY + nodeScale(0.5 * interpolated.f)}> {labelText}
{label}
</text> </text>
<text className="node-sublabel" textAnchor="middle" <text className="node-sublabel" textAnchor="middle" style={{fontSize: subLabelFontSize}}
style={{fontSize: interpolated.subLabelFontSize}} x="0" y={subLabelOffsetY + nodeScale(0.5 * scaleFactor)}>
x="0" y={interpolated.subLabelOffsetY + nodeScale(0.5 * interpolated.f)}> {subLabelText}
{subLabel}
</text> </text>
</g> </g>
); );
}}
</Motion>
);
}
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) { handleMouseClick(ev) {
@@ -144,3 +116,5 @@ export default class Node extends React.Component {
leaveNode(this.props.id); leaveNode(this.props.id);
} }
} }
reactMixin.onClass(Node, PureRenderMixin);

View File

@@ -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 (
<g className="nodes-chart-edges">
{layoutEdges.toIndexedSeq().map(edge => <EdgeContainer key={edge.get('id')}
id={edge.get('id')} source={edge.get('source')} target={edge.get('target')}
points={edge.get('points')} layoutPrecision={layoutPrecision}
highlightedEdgeIds={highlightedEdgeIds} hasSelectedNode={hasSelectedNode}
selectedNodeId={selectedNodeId} />)}
</g>
);
}
}
reactMixin.onClass(NodesChartEdges, PureRenderMixin);

View File

@@ -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 (
<g className="nodes-chart-elements" transform={props.transform}>
<NodesChartEdges layoutEdges={props.edges} selectedNodeId={props.selectedNodeId}
highlightedEdgeIds={props.highlightedEdgeIds}
hasSelectedNode={props.hasSelectedNode}
layoutPrecision={props.layoutPrecision} />
<NodesChartNodes layoutNodes={props.nodes} selectedNodeId={props.selectedNodeId}
selectedMetric={props.selectedMetric}
highlightedNodeIds={props.highlightedNodeIds}
hasSelectedNode={props.hasSelectedNode}
adjacentNodes={props.adjacentNodes}
nodeScale={props.nodeScale} onNodeClick={props.onNodeClick}
scale={props.scale} selectedNodeScale={props.selectedNodeScale}
topologyId={props.topologyId} layoutPrecision={props.layoutPrecision} />
</g>
);
}
}
reactMixin.onClass(NodesChartElements, PureRenderMixin);

View File

@@ -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 (
<g className="nodes-chart-nodes">
{nodesToRender.map(node => <NodeContainer
blurred={node.get('blurred')}
focused={node.get('focused')}
highlighted={node.get('highlighted')}
topologyId={topologyId}
shape={node.get('shape')}
stack={node.get('stack')}
onClick={onNodeClick}
key={node.get('id')}
id={node.get('id')}
label={node.get('label')}
pseudo={node.get('pseudo')}
nodeCount={node.get('nodeCount')}
subLabel={node.get('subLabel')}
metric={metric(node)}
rank={node.get('rank')}
layoutPrecision={layoutPrecision}
selectedNodeScale={selectedNodeScale}
nodeScale={nodeScale}
zoomScale={zoomScale}
dx={node.get('x')}
dy={node.get('y')} />)}
</g>
);
}
}
reactMixin.onClass(NodesChartNodes, PureRenderMixin);

View File

@@ -2,18 +2,17 @@ import _ from 'lodash';
import d3 from 'd3'; import d3 from 'd3';
import debug from 'debug'; import debug from 'debug';
import React from 'react'; 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 timely from 'timely';
import { DETAILS_PANEL_WIDTH } from '../constants/styles';
import { clickBackground } from '../actions/app-actions'; import { clickBackground } from '../actions/app-actions';
import AppStore from '../stores/app-store';
import Edge from './edge';
import { EDGE_ID_SEPARATOR } from '../constants/naming'; import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { doLayout } from './nodes-layout'; import { DETAILS_PANEL_WIDTH } from '../constants/styles';
import Node from './node';
import NodesError from './nodes-error';
import Logo from '../components/logo'; import Logo from '../components/logo';
import { doLayout } from './nodes-layout';
import NodesChartElements from './nodes-chart-elements';
const log = debug('scope:nodes-chart'); const log = debug('scope:nodes-chart');
@@ -29,20 +28,22 @@ const radiusDensity = d3.scale.threshold()
.domain([3, 6]).range([2.5, 3.5, 3]); .domain([3, 6]).range([2.5, 3.5, 3]);
export default class NodesChart extends React.Component { export default class NodesChart extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.handleMouseClick = this.handleMouseClick.bind(this); this.handleMouseClick = this.handleMouseClick.bind(this);
this.zoomed = this.zoomed.bind(this); this.zoomed = this.zoomed.bind(this);
this.state = { this.state = {
nodes: makeMap(),
edges: makeMap(), edges: makeMap(),
panTranslate: [0, 0], nodes: makeMap(),
scale: 1,
nodeScale: d3.scale.linear(), nodeScale: d3.scale.linear(),
panTranslateX: 0,
panTranslateY: 0,
scale: 1,
selectedNodeScale: d3.scale.linear(), selectedNodeScale: d3.scale.linear(),
hasZoomed: false, hasZoomed: false
maxNodesExceeded: false
}; };
} }
@@ -51,18 +52,6 @@ export default class NodesChart extends React.Component {
this.setState(state); 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) { componentWillReceiveProps(nextProps) {
// gather state, setState should be called only once here // gather state, setState should be called only once here
const state = _.assign({}, this.state); const state = _.assign({}, this.state);
@@ -91,9 +80,20 @@ export default class NodesChart extends React.Component {
this.setState(state); 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() { componentWillUnmount() {
// undoing .call(zoom) // undoing .call(zoom)
d3.select('.nodes-chart svg') d3.select('.nodes-chart svg')
.on('mousedown.zoom', null) .on('mousedown.zoom', null)
.on('onwheel', null) .on('onwheel', null)
@@ -102,170 +102,75 @@ export default class NodesChart extends React.Component {
.on('touchstart.zoom', null); .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 => <Node
blurred={node.get('blurred')}
focused={node.get('focused')}
highlighted={node.get('highlighted')}
topologyId={this.props.topologyId}
shape={node.get('shape')}
stack={node.get('stack')}
onClick={onNodeClick}
key={node.get('id')}
id={node.get('id')}
label={node.get('label')}
pseudo={node.get('pseudo')}
nodeCount={node.get('nodeCount')}
subLabel={node.get('subLabel')}
metric={metric(node)}
rank={node.get('rank')}
selectedNodeScale={selectedNodeScale}
nodeScale={nodeScale}
zoomScale={zoomScale}
dx={node.get('x')}
dy={node.get('y')}
/>);
}
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 => <Edge key={edge.get('id')} id={edge.get('id')}
points={edge.get('points')}
blurred={edge.get('blurred')} highlighted={edge.get('highlighted')}
/>
);
}
renderMaxNodesError(show) {
const errorHint = 'We\u0027re working on it, but for now, try a different view?';
return (
<NodesError faIconClass="fa-ban" hidden={!show}>
<div className="centered">Too many nodes to show in the browser.<br />{errorHint}</div>
</NodesError>
);
}
renderEmptyTopologyError(show) {
return (
<NodesError faIconClass="fa-circle-thin" hidden={!show}>
<div className="heading">Nothing to show. This can have any of these reasons:</div>
<ul>
<li>We haven't received any reports from probes recently.
Are the probes properly configured?</li>
<li>There are nodes, but they're currently hidden. Check the view options
in the bottom-left if they allow for showing hidden nodes.</li>
<li>Containers view only: you're not running Docker,
or you don't have any containers.</li>
</ul>
</NodesError>
);
}
render() { render() {
const nodeElements = this.renderGraphNodes(this.state.nodes, this.state.nodeScale); const { edges, nodes, panTranslateX, panTranslateY, scale } = this.state;
const edgeElements = this.renderGraphEdges(this.state.edges, this.state.nodeScale);
const scale = this.state.scale;
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 transform = `translate(${translate}) scale(${scale})`;
const svgClassNames = this.state.maxNodesExceeded || nodeElements.size === 0 ? 'hide' : ''; const svgClassNames = this.props.isEmpty ? 'hide' : '';
const errorEmpty = this.renderEmptyTopologyError(AppStore.isTopologyEmpty());
const errorMaxNodesExceeded = this.renderMaxNodesError(this.state.maxNodesExceeded);
return ( return (
<div className="nodes-chart"> <div className="nodes-chart">
{errorEmpty}
{errorMaxNodesExceeded}
<svg width="100%" height="100%" id="nodes-chart-canvas" <svg width="100%" height="100%" id="nodes-chart-canvas"
className={svgClassNames} onClick={this.handleMouseClick}> className={svgClassNames} onClick={this.handleMouseClick}>
<g transform="translate(24,24) scale(0.25)"> <g transform="translate(24,24) scale(0.25)">
<Logo /> <Logo />
</g> </g>
<g className="canvas" transform={transform}> <NodesChartElements
<g className="edges"> edges={edges}
{edgeElements} nodes={nodes}
</g> transform={transform}
<g className="nodes"> adjacentNodes={this.props.adjacentNodes}
{nodeElements} layoutPrecision={this.props.layoutPrecision}
</g> selectedMetric={this.props.selectedMetric}
</g> selectedNodeId={this.props.selectedNodeId}
highlightedEdgeIds={this.props.highlightedEdgeIds}
highlightedNodeIds={this.props.highlightedNodeIds}
hasSelectedNode={this.props.hasSelectedNode}
nodeScale={this.state.nodeScale}
scale={this.state.scale}
selectedNodeScale={this.state.selectedNodeScale}
topologyId={this.props.topologyId} />
</svg> </svg>
</div> </div>
); );
} }
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 // copy relevant fields to state nodes
return topology.map((node, id) => makeMap({ topology.forEach((node, id) => {
nextStateNodes = nextStateNodes.mergeIn([id], makeMap({
id, id,
label: node.get('label'), label: node.get('label'),
pseudo: node.get('pseudo'), pseudo: node.get('pseudo'),
metrics: node.get('metrics'),
subLabel: node.get('label_minor'), subLabel: node.get('label_minor'),
nodeCount: node.get('node_count'), nodeCount: node.get('node_count'),
metrics: node.get('metrics'),
rank: node.get('rank'), rank: node.get('rank'),
shape: node.get('shape'), shape: node.get('shape'),
stack: node.get('stack'), stack: node.get('stack')
x: 0,
y: 0
})); }));
});
return nextStateNodes;
} }
initEdges(topology, stateNodes) { initEdges(topology, stateNodes) {
@@ -309,10 +214,10 @@ export default class NodesChart extends React.Component {
return {}; return {};
} }
const adjacency = AppStore.getAdjacentNodes(props.selectedNodeId); const adjacentNodes = props.adjacentNodes;
const adjacentLayoutNodeIds = []; const adjacentLayoutNodeIds = [];
adjacency.forEach(adjacentId => { adjacentNodes.forEach(adjacentId => {
// filter loopback // filter loopback
if (adjacentId !== props.selectedNodeId) { if (adjacentId !== props.selectedNodeId) {
adjacentLayoutNodeIds.push(adjacentId); adjacentLayoutNodeIds.push(adjacentId);
@@ -321,7 +226,7 @@ export default class NodesChart extends React.Component {
// move origin node to center of viewport // move origin node to center of viewport
const zoomScale = state.scale; const zoomScale = state.scale;
const translate = state.panTranslate; const translate = [state.panTranslateX, state.panTranslateY];
const centerX = (-translate[0] + (props.width + MARGINS.left const centerX = (-translate[0] + (props.width + MARGINS.left
- DETAILS_PANEL_WIDTH) / 2) / zoomScale; - DETAILS_PANEL_WIDTH) / 2) / zoomScale;
const centerY = (-translate[1] + (props.height + MARGINS.top) / 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'))) { || _.includes(adjacentLayoutNodeIds, edge.get('target'))) {
const source = stateNodes.get(edge.get('source')); const source = stateNodes.get(edge.get('source'));
const target = stateNodes.get(edge.get('target')); 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: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')} {x: target.get('x'), y: target.get('y')}
]); ]));
} }
return edge; return edge;
}); });
@@ -374,18 +279,10 @@ export default class NodesChart extends React.Component {
}; };
} }
handleMouseClick() {
if (!this.isZooming) {
clickBackground();
} else {
this.isZooming = false;
}
}
restoreLayout(state) { restoreLayout(state) {
// undo any pan/zooming that might have happened // undo any pan/zooming that might have happened
this.zoom.scale(state.scale); 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({ const nodes = state.nodes.map(node => node.merge({
x: node.get('px'), x: node.get('px'),
@@ -412,9 +309,13 @@ export default class NodesChart extends React.Component {
}; };
} }
let stateNodes = this.initNodes(props.nodes, state.nodes); const stateNodes = this.initNodes(props.nodes, state.nodes);
let stateEdges = this.initEdges(props.nodes, stateNodes); const stateEdges = this.initEdges(props.nodes, stateNodes);
const nodeMetrics = stateNodes.map(node => makeMap({
metrics: node.get('metrics')
}));
const nodeScale = this.getNodeScale(props); const nodeScale = this.getNodeScale(props);
const nextState = { nodeScale };
const options = { const options = {
width: props.width, width: props.width,
@@ -431,21 +332,15 @@ export default class NodesChart extends React.Component {
log(`graph layout took ${timedLayouter.time}ms`); log(`graph layout took ${timedLayouter.time}ms`);
// layout was aborted // inject metrics and save coordinates for restore
if (!graph) { const layoutNodes = graph.nodes
return {maxNodesExceeded: true}; .mergeDeep(nodeMetrics)
} .map(node => node.merge({
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'), px: node.get('x'),
py: node.get('y') py: node.get('y')
})); }));
stateEdges = stateEdges.map(edge => edge.set('ppoints', edge.get('points'))); const layoutEdges = graph.edges
.map(edge => edge.set('ppoints', edge.get('points')));
// adjust layout based on viewport // adjust layout based on viewport
const xFactor = (props.width - MARGINS.left - MARGINS.right) / graph.width; 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); const zoomFactor = Math.min(xFactor, yFactor);
let zoomScale = this.state.scale; 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; zoomScale = zoomFactor;
// saving in d3's behavior cache // saving in d3's behavior cache
this.zoom.scale(zoomFactor); this.zoom.scale(zoomFactor);
} }
return { nextState.scale = zoomScale;
nodes: stateNodes, if (!isDeepEqual(stateNodes, state.nodes)) {
edges: stateEdges, nextState.nodes = layoutNodes;
scale: zoomScale, }
nodeScale, if (!isDeepEqual(stateEdges, state.edges)) {
maxNodesExceeded: false nextState.edges = layoutEdges;
}; }
return nextState;
} }
getNodeScale(props) { getNodeScale(props) {
@@ -483,9 +380,12 @@ export default class NodesChart extends React.Component {
if (!this.props.selectedNodeId) { if (!this.props.selectedNodeId) {
this.setState({ this.setState({
hasZoomed: true, hasZoomed: true,
panTranslate: d3.event.translate.slice(), panTranslateX: d3.event.translate[0],
panTranslateY: d3.event.translate[1],
scale: d3.event.scale scale: d3.event.scale
}); });
} }
} }
} }
reactMixin.onClass(NodesChart, PureRenderMixin);

View File

@@ -1,13 +1,12 @@
import dagre from 'dagre'; import dagre from 'dagre';
import debug from 'debug'; 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 { EDGE_ID_SEPARATOR } from '../constants/naming';
import { updateNodeDegrees } from '../utils/topology-utils'; import { updateNodeDegrees } from '../utils/topology-utils';
const log = debug('scope:nodes-layout'); const log = debug('scope:nodes-layout');
const MAX_NODES = 100;
const topologyCaches = {}; const topologyCaches = {};
const DEFAULT_WIDTH = 800; const DEFAULT_WIDTH = 800;
const DEFAULT_MARGINS = {top: 0, left: 0}; const DEFAULT_MARGINS = {top: 0, left: 0};
@@ -51,11 +50,6 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) {
let nodes = imNodes; let nodes = imNodes;
let edges = imEdges; 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 options = opts || {};
const scale = options.scale || DEFAULT_SCALE; const scale = options.scale || DEFAULT_SCALE;
const ranksep = scale(RANK_SEPARATION_FACTOR); const ranksep = scale(RANK_SEPARATION_FACTOR);
@@ -122,13 +116,13 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) {
graph.edges().forEach(graphEdge => { graph.edges().forEach(graphEdge => {
const graphEdgeMeta = graph.edge(graphEdge); const graphEdgeMeta = graph.edge(graphEdge);
const edge = edges.get(graphEdgeMeta.id); 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 // set beginning and end points to node coordinates to ignore node bounding box
const source = nodes.get(fromGraphNodeId(edge.get('source'))); const source = nodes.get(fromGraphNodeId(edge.get('source')));
const target = nodes.get(fromGraphNodeId(edge.get('target'))); const target = nodes.get(fromGraphNodeId(edge.get('target')));
points[0] = {x: source.get('x'), y: source.get('y')}; points = points.mergeIn([0], {x: source.get('x'), y: source.get('y')});
points[points.length - 1] = {x: target.get('x'), y: target.get('y')}; points = points.mergeIn([points.size - 1], {x: target.get('x'), y: target.get('y')});
edges = edges.setIn([graphEdgeMeta.id, 'points'], points); edges = edges.setIn([graphEdgeMeta.id, 'points'], points);
}); });
@@ -251,13 +245,12 @@ function shiftLayoutToCenter(layout, opts) {
y: node.get('y') + offsetY y: node.get('y') + offsetY
})); }));
result.edges = layout.edges.map(edge => { result.edges = layout.edges.map(edge => edge.update('points',
const points = edge.get('points').map(point => ({ points => points.map(point => point.merge({
x: point.x + offsetX, x: point.get('x') + offsetX,
y: point.y + offsetY y: point.get('y') + offsetY
})); }))
return edge.set('points', points); ));
});
return result; return result;
} }
@@ -271,10 +264,10 @@ function shiftLayoutToCenter(layout, opts) {
function setSimpleEdgePoints(edge, nodeCache) { function setSimpleEdgePoints(edge, nodeCache) {
const source = nodeCache.get(edge.get('source')); const source = nodeCache.get(edge.get('source'));
const target = nodeCache.get(edge.get('target')); 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: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')} {x: target.get('x'), y: target.get('y')}
]); ]));
} }
/** /**
@@ -300,14 +293,14 @@ export function hasUnseenNodes(nodes, cache) {
*/ */
function hasSameEndpoints(cachedEdge, nodes) { function hasSameEndpoints(cachedEdge, nodes) {
const oldPoints = cachedEdge.get('points'); const oldPoints = cachedEdge.get('points');
const oldSourcePoint = oldPoints[0]; const oldSourcePoint = oldPoints.first();
const oldTargetPoint = oldPoints[oldPoints.length - 1]; const oldTargetPoint = oldPoints.last();
const newSource = nodes.get(cachedEdge.get('source')); const newSource = nodes.get(cachedEdge.get('source'));
const newTarget = nodes.get(cachedEdge.get('target')); const newTarget = nodes.get(cachedEdge.get('target'));
return (oldSourcePoint.x === newSource.get('x') return (oldSourcePoint.get('x') === newSource.get('x')
&& oldSourcePoint.y === newSource.get('y') && oldSourcePoint.get('y') === newSource.get('y')
&& oldTargetPoint.x === newTarget.get('x') && oldTargetPoint.get('x') === newTarget.get('x')
&& oldTargetPoint.y === newTarget.get('y')); && oldTargetPoint.get('y') === newTarget.get('y'));
} }
/** /**

View File

@@ -1,5 +1,7 @@
import React from 'react';
import debug from 'debug'; 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 Logo from './logo';
import AppStore from '../stores/app-store'; 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 LEFT_ANGLE_KEY_IDENTIFIER = 'U+003E';
const keyPressLog = debug('scope:app-key-press'); const keyPressLog = debug('scope:app-key-press');
/* make sure these can all be shallow-checked for equality for PureRenderMixin */
function getStateFromStores() { function getStateFromStores() {
return { return {
activeTopologyOptions: AppStore.getActiveTopologyOptions(), activeTopologyOptions: AppStore.getActiveTopologyOptions(),
adjacentNodes: AppStore.getAdjacentNodes(AppStore.getSelectedNodeId()),
controlStatus: AppStore.getControlStatus(), controlStatus: AppStore.getControlStatus(),
controlPipe: AppStore.getControlPipe(), controlPipe: AppStore.getControlPipe(),
currentTopology: AppStore.getCurrentTopology(), currentTopology: AppStore.getCurrentTopology(),
@@ -47,6 +51,7 @@ function getStateFromStores() {
selectedMetric: AppStore.getSelectedMetric(), selectedMetric: AppStore.getSelectedMetric(),
topologies: AppStore.getTopologies(), topologies: AppStore.getTopologies(),
topologiesLoaded: AppStore.isTopologiesLoaded(), topologiesLoaded: AppStore.isTopologiesLoaded(),
topologyEmpty: AppStore.isTopologyEmpty(),
updatePaused: AppStore.isUpdatePaused(), updatePaused: AppStore.isUpdatePaused(),
updatePausedAt: AppStore.getUpdatePausedAt(), updatePausedAt: AppStore.getUpdatePausedAt(),
version: AppStore.getVersion(), version: AppStore.getVersion(),
@@ -136,6 +141,8 @@ export default class App extends React.Component {
selectedMetric={this.state.selectedMetric} selectedMetric={this.state.selectedMetric}
forceRelayout={this.state.forceRelayout} forceRelayout={this.state.forceRelayout}
topologyOptions={this.state.activeTopologyOptions} topologyOptions={this.state.activeTopologyOptions}
topologyEmpty={this.state.topologyEmpty}
adjacentNodes={this.state.adjacentNodes}
topologyId={this.state.currentTopologyId} /> topologyId={this.state.currentTopologyId} />
<Sidebar> <Sidebar>
@@ -157,3 +164,5 @@ export default class App extends React.Component {
); );
} }
} }
reactMixin.onClass(App, PureRenderMixin);

View File

@@ -1,6 +1,7 @@
/* eslint react/jsx-no-bind: "off" */ /* eslint react/jsx-no-bind: "off" */
import React from 'react'; import React from 'react';
import _ from 'lodash'; import _ from 'lodash';
import Perf from 'react-addons-perf';
import debug from 'debug'; import debug from 'debug';
const log = debug('scope:debug-panel'); const log = debug('scope:debug-panel');
@@ -31,7 +32,7 @@ const LABEL_PREFIXES = _.range('A'.charCodeAt(), 'Z'.charCodeAt() + 1)
.map(n => String.fromCharCode(n)); .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) => ({ 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) { function addNodes(n) {
const ns = AppStore.getNodes(); const ns = AppStore.getNodes();
const nodeNames = ns.keySeq().toJS(); const nodeNames = ns.keySeq().toJS();
const newNodeNames = _.range(ns.size, ns.size + n).map(() => ( const newNodeNames = _.range(ns.size, ns.size + n).map(i => (
`${randomLetter()}${randomLetter()}-zing` // `${randomLetter()}${randomLetter()}-zing`
`zing${i}`
)); ));
const allNodes = _(nodeNames).concat(newNodeNames).value(); const allNodes = _(nodeNames).concat(newNodeNames).value();
@@ -110,9 +125,9 @@ function addNodes(n) {
}); });
} }
export function showingDebugToolbar() { 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 {
</tbody> </tbody>
</table> </table>
))} ))}
<div>
<label>Measure React perf for </label>
<button onClick={() => startPerf(2)}>2s</button>
<button onClick={() => startPerf(5)}>5s</button>
<button onClick={() => startPerf(10)}>10s</button>
</div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,6 @@
import React from 'react'; import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import reactMixin from 'react-mixin';
import NodeDetails from './node-details'; import NodeDetails from './node-details';
import { DETAILS_PANEL_WIDTH as WIDTH, DETAILS_PANEL_OFFSET as OFFSET, 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);

View File

@@ -8,7 +8,7 @@ export default function Details({controlStatus, details, nodes}) {
<div className="details"> <div className="details">
{details.toIndexedSeq().map((obj, index) => <DetailsCard key={obj.id} {details.toIndexedSeq().map((obj, index) => <DetailsCard key={obj.id}
index={index} cardCount={details.size} nodes={nodes} index={index} cardCount={details.size} nodes={nodes}
nodeControlStatus={controlStatus[obj.id]} {...obj} /> nodeControlStatus={controlStatus.get(obj.id)} {...obj} />
)} )}
</div> </div>
); );

View File

@@ -135,7 +135,8 @@ export default class NodeDetails extends React.Component {
const details = this.props.details; const details = this.props.details;
const showControls = details.controls && details.controls.length > 0; const showControls = details.controls && details.controls.length > 0;
const nodeColor = getNodeColorDark(details.rank, details.label, details.pseudo); 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 tools = this.renderTools();
const styles = { const styles = {
controls: { controls: {

View File

@@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import reactMixin from 'react-mixin';
import { clickRelative } from '../../actions/app-actions'; import { clickRelative } from '../../actions/app-actions';
@@ -25,3 +27,5 @@ export default class NodeDetailsRelativesLink extends React.Component {
); );
} }
} }
reactMixin.onClass(NodeDetailsRelativesLink, PureRenderMixin);

View File

@@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import reactMixin from 'react-mixin';
import { clickRelative } from '../../actions/app-actions'; import { clickRelative } from '../../actions/app-actions';
@@ -32,3 +34,5 @@ export default class NodeDetailsTableNodeLink extends React.Component {
); );
} }
} }
reactMixin.onClass(NodeDetailsTableNodeLink, PureRenderMixin);

View File

@@ -1,10 +1,31 @@
import React from 'react'; import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import reactMixin from 'react-mixin';
import NodesChart from '../charts/nodes-chart'; import NodesChart from '../charts/nodes-chart';
import NodesError from '../charts/nodes-error';
const navbarHeight = 160; const navbarHeight = 160;
const marginTop = 0; 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 { export default class Nodes extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@@ -24,8 +45,37 @@ export default class Nodes extends React.Component {
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
} }
renderEmptyTopologyError(show) {
return (
<NodesError faIconClass="fa-circle-thin" hidden={!show}>
<div className="heading">Nothing to show. This can have any of these reasons:</div>
<ul>
<li>We haven't received any reports from probes recently.
Are the probes properly configured?</li>
<li>There are nodes, but they're currently hidden. Check the view options
in the bottom-left if they allow for showing hidden nodes.</li>
<li>Containers view only: you're not running Docker,
or you don't have any containers.</li>
</ul>
</NodesError>
);
}
render() { render() {
return <NodesChart {...this.props} {...this.state} />; const { nodes, selectedNodeId, topologyEmpty } = this.props;
const layoutPrecision = getLayoutPrecision(nodes.size);
const hasSelectedNode = selectedNodeId && nodes.has(selectedNodeId);
const errorEmpty = this.renderEmptyTopologyError(topologyEmpty);
return (
<div className="nodes-wrapper">
{topologyEmpty && errorEmpty}
<NodesChart {...this.props} {...this.state}
layoutPrecision={layoutPrecision}
hasSelectedNode={hasSelectedNode}
/>
</div>
);
} }
handleResize() { handleResize() {
@@ -39,3 +89,5 @@ export default class Nodes extends React.Component {
this.setState({height, width}); this.setState({height, width});
} }
} }
reactMixin.onClass(Nodes, PureRenderMixin);

View File

@@ -1,4 +1,6 @@
import React from 'react'; import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import reactMixin from 'react-mixin';
export default class ShowMore extends React.Component { export default class ShowMore extends React.Component {
@@ -28,3 +30,5 @@ export default class ShowMore extends React.Component {
); );
} }
} }
reactMixin.onClass(ShowMore, PureRenderMixin);

View File

@@ -1,4 +1,6 @@
import React from 'react'; import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import reactMixin from 'react-mixin';
import { clickTopology } from '../actions/app-actions'; import { clickTopology } from '../actions/app-actions';
@@ -69,3 +71,5 @@ export default class Topologies extends React.Component {
); );
} }
} }
reactMixin.onClass(Topologies, PureRenderMixin);

View File

@@ -1,4 +1,6 @@
import React from 'react'; import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import reactMixin from 'react-mixin';
import { changeTopologyOption } from '../actions/app-actions'; import { changeTopologyOption } from '../actions/app-actions';
@@ -26,3 +28,5 @@ export default class TopologyOptionAction extends React.Component {
); );
} }
} }
reactMixin.onClass(TopologyOptionAction, PureRenderMixin);

View File

@@ -1,4 +1,6 @@
import React from 'react'; import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import reactMixin from 'react-mixin';
import TopologyOptionAction from './topology-option-action'; import TopologyOptionAction from './topology-option-action';
@@ -29,3 +31,5 @@ export default class TopologyOptions extends React.Component {
); );
} }
} }
reactMixin.onClass(TopologyOptions, PureRenderMixin);

View File

@@ -142,9 +142,10 @@ export class AppStore extends Store {
// keep at the top // keep at the top
getAppState() { getAppState() {
const cp = this.getControlPipe();
return { return {
controlPipe: this.getControlPipe(), controlPipe: cp ? cp.toJS() : null,
nodeDetails: this.getNodeDetailsState(), nodeDetails: this.getNodeDetailsState().toJS(),
selectedNodeId, selectedNodeId,
pinnedMetricType, pinnedMetricType,
topologyId: currentTopologyId, topologyId: currentTopologyId,
@@ -190,12 +191,11 @@ export class AppStore extends Store {
} }
getControlStatus() { getControlStatus() {
return controlStatus.toJS(); return controlStatus;
} }
getControlPipe() { getControlPipe() {
const cp = controlPipes.last(); return controlPipes.last();
return cp && cp.toJS();
} }
getCurrentTopology() { getCurrentTopology() {
@@ -240,7 +240,7 @@ export class AppStore extends Store {
getNodeDetailsState() { getNodeDetailsState() {
return nodeDetails.toIndexedSeq().map(details => ({ return nodeDetails.toIndexedSeq().map(details => ({
id: details.id, label: details.label, topologyId: details.topologyId id: details.id, label: details.label, topologyId: details.topologyId
})).toJS(); }));
} }
getTopCardNodeId() { getTopCardNodeId() {

View File

@@ -333,7 +333,7 @@ h2 {
} }
} }
.nodes > .node { .nodes-chart-nodes > .node {
cursor: pointer; cursor: pointer;
transition: opacity .5s @base-ease; transition: opacity .5s @base-ease;
@@ -370,6 +370,10 @@ h2 {
opacity: @edge-opacity-blurred; opacity: @edge-opacity-blurred;
} }
&.focused {
animation: focusing 1.5s ease-in-out;
}
.link { .link {
stroke: @text-secondary-color; stroke: @text-secondary-color;
stroke-width: @edge-link-stroke-width; stroke-width: @edge-link-stroke-width;
@@ -1058,6 +1062,16 @@ h2 {
font-size: .7rem; font-size: .7rem;
} }
@keyframes focusing {
0% {
opacity: 0;
} 33% {
opacity: 0.2;
} 100% {
opacity: 1;
}
}
@keyframes blinking { @keyframes blinking {
0%, 100% { 0%, 100% {
opacity: 1.0; opacity: 1.0;

20
client/build/debug.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html class="no-js">
<head>
<meta charset="utf-8">
<title>Weave Scope</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<!--[if lt IE 10]>
<p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
<![endif]-->
<div class="wrap">
<div id="app"></div>
</div>
<script src="vendors.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -25,6 +25,7 @@
"react-addons-update": "^0.14.7", "react-addons-update": "^0.14.7",
"react-dom": "^0.14.7", "react-dom": "^0.14.7",
"react-motion": "0.3.1", "react-motion": "0.3.1",
"react-mixin": "^3.0.3",
"reqwest": "~2.0.5", "reqwest": "~2.0.5",
"timely": "0.1.0" "timely": "0.1.0"
}, },
@@ -50,14 +51,17 @@
"less": "~2.6.1", "less": "~2.6.1",
"less-loader": "2.2.2", "less-loader": "2.2.2",
"postcss-loader": "0.8.2", "postcss-loader": "0.8.2",
"react-addons-perf": "^0.14.0",
"style-loader": "0.13.0", "style-loader": "0.13.0",
"url": "0.11.0", "url": "0.11.0",
"url-loader": "0.5.7", "url-loader": "0.5.7",
"webpack": "~1.12.4" "webpack": "~1.12.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"browser-perf": "^1.4.5",
"express": "~4.13.3", "express": "~4.13.3",
"http-proxy": "^1.12.0", "http-proxy": "^1.12.0",
"perfjankie": "^2.1.0",
"react-hot-loader": "~1.3.0", "react-hot-loader": "~1.3.0",
"webpack-dev-server": "~1.14.1" "webpack-dev-server": "~1.14.1"
}, },

View File

@@ -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);
});
}
});
});
}
};

View File

@@ -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);

View File

@@ -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,
}
});

34
client/test/run-jankie.sh Executable file
View File

@@ -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