Performance tweaks

Add debug.html to show toolbar

Perfjankie test runner

Playing w/ the pure mixin for perf. improvements

* Works well! Smoother zooming/panning when things have settled.
* Extract node movement to node-container, make nodes pure

Extracted node chart elements into own components

Keep control objects immutable while in components

Keep layout state objects alive

Made other components pure, removed mixin from stateless components

Remove font size adjustment from scaling

Fix zoomscale

Move node transform to node

* makes more sense there because the coords are rounded in the container

dynamic coords precision based on topology size

Make edge points immutable

Remove nodes maximum for layout engine

Dont send all canvas state down to next component

moving layout handling back to nodes-chart.js

Omit some props for edges/nodes, dont animate edges on low precision

Moved AppStore access out of lower components
This commit is contained in:
David Kaltschmidt
2016-03-30 15:49:01 +02:00
parent c9b323ff84
commit d520cffec7
32 changed files with 790 additions and 401 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')
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

View File

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

1
client/.gitignore vendored
View File

@@ -2,3 +2,4 @@ node_modules
build/app.js
build/*[woff2?|ttf|eot|svg]
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,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 (
<Motion style={points}>
{(interpolated) => {
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="link" />
</g>
);
}}
</Motion>
<g className={classes} onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave} id={id}>
<path d={path} className="shadow" />
<path d={path} className="link" />
</g>
);
}
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);

View File

@@ -0,0 +1,27 @@
import _ from 'lodash';
import React from 'react';
import d3 from 'd3';
import { Motion, spring } from 'react-motion';
import Node from './node';
export default function NodeContainer(props) {
const { dx, dy, focused, layoutPrecision, zoomScale } = props;
const animConfig = [80, 20]; // stiffness, damping
const scaleFactor = focused ? (2 / zoomScale) : 1;
const other = _.omit(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>
);
}

View File

@@ -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 (
<Motion style={{
x: spring(this.props.dx, animConfig),
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
size={nodeScale(interpolated.f)}
color={color}
{...props} />
<text className="node-label" textAnchor="middle"
style={{fontSize: interpolated.labelFontSize}}
x="0" y={interpolated.labelOffsetY + nodeScale(0.5 * interpolated.f)}>
{label}
</text>
<text className="node-sublabel" textAnchor="middle"
style={{fontSize: interpolated.subLabelFontSize}}
x="0" y={interpolated.subLabelOffsetY + nodeScale(0.5 * interpolated.f)}>
{subLabel}
</text>
</g>
);
}}
</Motion>
<g className={className} transform={transform} onClick={this.handleMouseClick}
onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<NodeShapeType
size={nodeScale(scaleFactor)}
color={color}
{...this.props} />
<text className="node-label" textAnchor="middle" style={{fontSize: labelFontSize}}
x="0" y={labelOffsetY + nodeScale(0.5 * scaleFactor)}>
{labelText}
</text>
<text className="node-sublabel" textAnchor="middle" style={{fontSize: subLabelFontSize}}
x="0" y={subLabelOffsetY + nodeScale(0.5 * scaleFactor)}>
{subLabelText}
</text>
</g>
);
}
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);

View File

@@ -0,0 +1,16 @@
import React from 'react';
import EdgeContainer from './edge-container';
export default function NodesChartEdges({hasSelectedNode, highlightedEdgeIds,
layoutEdges, layoutPrecision, selectedNodeId}) {
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>
);
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import NodesChartEdges from './nodes-chart-edges';
import NodesChartNodes from './nodes-chart-nodes';
export default function NodesChartElements(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>
);
}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import NodeContainer from './node-container';
export default function NodesChartNodes({adjacentNodes, highlightedNodeIds,
layoutNodes, layoutPrecision, nodeScale, onNodeClick, scale,
selectedMetric, selectedNodeScale, selectedNodeId, topologyId}) {
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>
);
}

View File

@@ -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,74 @@ 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 => <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() {
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 (
<div className="nodes-chart">
{errorEmpty}
{errorMaxNodesExceeded}
<svg width="100%" height="100%" id="nodes-chart-canvas"
className={svgClassNames} onClick={this.handleMouseClick}>
<g transform="translate(24,24) scale(0.25)">
<Logo />
</g>
<g className="canvas" transform={transform}>
<g className="edges">
{edgeElements}
</g>
<g className="nodes">
{nodeElements}
</g>
</g>
<NodesChartElements
edges={edges}
nodes={nodes}
transform={transform}
adjacentNodes={this.props.adjacentNodes}
layoutPrecision={this.props.layoutPrecision}
selectedMetric={this.props.selectedMetric}
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>
</div>
);
}
initNodes(topology) {
handleMouseClick() {
if (!this.isZooming) {
clickBackground();
} else {
this.isZooming = false;
}
}
initNodes(topology, stateNodes) {
let nextStateNodes = stateNodes;
// remove nodes that have dissappeared
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'),
rank: node.get('rank'),
shape: node.get('shape'),
stack: node.get('stack')
}));
});
return nextStateNodes;
}
initEdges(topology, stateNodes) {
@@ -309,10 +213,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 +225,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 +260,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 +278,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 +295,7 @@ export default class NodesChart extends React.Component {
return edge;
});
return { edges, nodes};
return { edges, nodes };
}
updateGraphState(props, state) {
@@ -412,9 +308,10 @@ 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 nodeScale = this.getNodeScale(props);
const nextState = { nodeScale };
const options = {
width: props.width,
@@ -431,21 +328,17 @@ 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(stateNodes.map(node => makeMap({
metrics: node.get('metrics')
})))
.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 +346,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 +378,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);

View File

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

View File

@@ -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} />
<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" */
import React from 'react';
import _ from 'lodash';
import Perf from 'react-addons-perf';
import debug from 'debug';
const log = debug('scope:debug-panel');
@@ -91,6 +92,19 @@ 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();
@@ -110,9 +124,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 +210,13 @@ export class DebugToolbar extends React.Component {
</tbody>
</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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<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() {
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() {
@@ -39,3 +89,5 @@ export default class Nodes extends React.Component {
this.setState({height, width});
}
}
reactMixin.onClass(Nodes, PureRenderMixin);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

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-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"
},

View File

@@ -0,0 +1,118 @@
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 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,
}
});

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

@@ -0,0 +1,16 @@
#!/bin/bash
# run jankie on one commit
set -x
HOST=$1
COMMIT=$2
DATE=$3
git checkout $COMMIT
make SUDO= -C ../..
../../scope stop && ../../scope launch
sleep 5
COMMIT=$COMMIT DATE=$DATE HOST=$HOST DEBUG=scope* node ./perfjankie/main.js