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