diff --git a/client/.eslintrc b/client/.eslintrc index aa6943f9e..7320a3b9f 100644 --- a/client/.eslintrc +++ b/client/.eslintrc @@ -7,6 +7,7 @@ }, "rules": { "comma-dangle": 0, + "no-param-reassign": 0, "object-curly-spacing": 0, "react/jsx-closing-bracket-location": 0, "react/prefer-stateless-function": 0, diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 1b654a934..1202cd371 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -1,6 +1,5 @@ import debug from 'debug'; -import AppDispatcher from '../dispatcher/app-dispatcher'; import ActionTypes from '../constants/action-types'; import { saveGraph } from '../utils/file-utils'; import { modulo } from '../utils/math-utils'; @@ -9,108 +8,123 @@ import { bufferDeltaUpdate, resumeUpdate, resetUpdateBuffer } from '../utils/update-buffer-utils'; import { doControlRequest, getNodesDelta, getNodeDetails, getTopologies, deletePipe } from '../utils/web-api-utils'; -import AppStore from '../stores/app-store'; +import { getActiveTopologyOptions, + getCurrentTopologyUrl } from '../utils/topology-utils'; const log = debug('scope:app-actions'); export function showHelp() { - AppDispatcher.dispatch({type: ActionTypes.SHOW_HELP}); + return {type: ActionTypes.SHOW_HELP}; } export function hideHelp() { - AppDispatcher.dispatch({type: ActionTypes.HIDE_HELP}); + return {type: ActionTypes.HIDE_HELP}; } export function toggleHelp() { - if (AppStore.getShowingHelp()) { - hideHelp(); - } else { - showHelp(); - } + return (dispatch, getState) => { + if (getState().get('showingHelp')) { + dispatch(hideHelp()); + } else { + dispatch(showHelp()); + } + }; } export function selectMetric(metricId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.SELECT_METRIC, metricId - }); -} - -export function pinNextMetric(delta) { - const metrics = AppStore.getAvailableCanvasMetrics().map(m => m.get('id')); - const currentIndex = metrics.indexOf(AppStore.getSelectedMetric()); - const nextIndex = modulo(currentIndex + delta, metrics.count()); - const nextMetric = metrics.get(nextIndex); - - AppDispatcher.dispatch({ - type: ActionTypes.PIN_METRIC, - metricId: nextMetric, - }); - updateRoute(); + }; } export function pinMetric(metricId) { - AppDispatcher.dispatch({ - type: ActionTypes.PIN_METRIC, - metricId, - }); - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.PIN_METRIC, + metricId, + }); + updateRoute(getState); + }; } export function unpinMetric() { - AppDispatcher.dispatch({ - type: ActionTypes.UNPIN_METRIC, - }); - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.UNPIN_METRIC, + }); + updateRoute(getState); + }; +} + +export function pinNextMetric(delta) { + return (dispatch, getState) => { + const state = getState(); + const metrics = state.get('availableCanvasMetrics').map(m => m.get('id')); + const currentIndex = metrics.indexOf(state.get('selectedMetric')); + const nextIndex = modulo(currentIndex + delta, metrics.count()); + const nextMetric = metrics.get(nextIndex); + + dispatch(pinMetric(nextMetric)); + }; } export function changeTopologyOption(option, value, topologyId) { - AppDispatcher.dispatch({ - type: ActionTypes.CHANGE_TOPOLOGY_OPTION, - topologyId, - option, - value - }); - updateRoute(); - // update all request workers with new options - resetUpdateBuffer(); - getTopologies( - AppStore.getActiveTopologyOptions() - ); - getNodesDelta( - AppStore.getCurrentTopologyUrl(), - AppStore.getActiveTopologyOptions() - ); - getNodeDetails( - AppStore.getTopologyUrlsById(), - AppStore.getNodeDetails() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CHANGE_TOPOLOGY_OPTION, + topologyId, + option, + value + }); + updateRoute(getState); + // update all request workers with new options + resetUpdateBuffer(); + const state = getState(); + getTopologies(getActiveTopologyOptions(state), dispatch); + getNodesDelta( + getCurrentTopologyUrl(state), + getActiveTopologyOptions(state), + dispatch + ); + getNodeDetails( + state.get('topologyUrlsById'), + state.get('nodeDetails'), + dispatch + ); + }; } export function clickBackground() { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_BACKGROUND - }); - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_BACKGROUND + }); + updateRoute(getState); + }; } export function clickCloseDetails(nodeId) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_CLOSE_DETAILS, - nodeId - }); - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_CLOSE_DETAILS, + nodeId + }); + updateRoute(getState); + }; } export function clickCloseTerminal(pipeId, closePipe) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_CLOSE_TERMINAL, - pipeId - }); - if (closePipe) { - deletePipe(pipeId); - } - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_CLOSE_TERMINAL, + pipeId + }); + if (closePipe) { + deletePipe(pipeId, dispatch); + } + updateRoute(getState); + }; } export function clickDownloadGraph() { @@ -118,285 +132,340 @@ export function clickDownloadGraph() { } export function clickForceRelayout() { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_FORCE_RELAYOUT - }); + return (dispatch) => { + dispatch({ + type: ActionTypes.CLICK_FORCE_RELAYOUT, + forceRelayout: true + }); + // fire only once, reset after dispatch + setTimeout(() => { + dispatch({ + type: ActionTypes.CLICK_FORCE_RELAYOUT, + forceRelayout: false + }); + }, 100); + }; } export function clickNode(nodeId, label, origin) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_NODE, - origin, - label, - nodeId - }); - updateRoute(); - getNodeDetails( - AppStore.getTopologyUrlsById(), - AppStore.getNodeDetails() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_NODE, + origin, + label, + nodeId + }); + updateRoute(getState); + const state = getState(); + getNodeDetails( + state.get('topologyUrlsById'), + state.get('nodeDetails'), + dispatch + ); + }; } export function clickPauseUpdate() { - AppDispatcher.dispatch({ + return { type: ActionTypes.CLICK_PAUSE_UPDATE - }); + }; } export function clickRelative(nodeId, topologyId, label, origin) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_RELATIVE, - label, - origin, - nodeId, - topologyId - }); - updateRoute(); - getNodeDetails( - AppStore.getTopologyUrlsById(), - AppStore.getNodeDetails() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_RELATIVE, + label, + origin, + nodeId, + topologyId + }); + updateRoute(getState); + const state = getState(); + getNodeDetails( + state.get('topologyUrlsById'), + state.get('nodeDetails'), + dispatch + ); + }; } export function clickResumeUpdate() { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_RESUME_UPDATE - }); - resumeUpdate(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_RESUME_UPDATE + }); + resumeUpdate(getState); + }; } export function clickShowTopologyForNode(topologyId, nodeId) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, - topologyId, - nodeId - }); - updateRoute(); - resetUpdateBuffer(); - getNodesDelta( - AppStore.getCurrentTopologyUrl(), - AppStore.getActiveTopologyOptions() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, + topologyId, + nodeId + }); + updateRoute(getState); + // update all request workers with new options + resetUpdateBuffer(); + const state = getState(); + getNodesDelta( + getCurrentTopologyUrl(state), + getActiveTopologyOptions(state), + dispatch + ); + }; } export function clickTopology(topologyId) { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_TOPOLOGY, - topologyId - }); - updateRoute(); - resetUpdateBuffer(); - getNodesDelta( - AppStore.getCurrentTopologyUrl(), - AppStore.getActiveTopologyOptions() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.CLICK_TOPOLOGY, + topologyId + }); + updateRoute(getState); + // update all request workers with new options + resetUpdateBuffer(); + const state = getState(); + getNodesDelta( + getCurrentTopologyUrl(state), + getActiveTopologyOptions(state), + dispatch + ); + }; } export function openWebsocket() { - AppDispatcher.dispatch({ + return { type: ActionTypes.OPEN_WEBSOCKET - }); + }; } export function clearControlError(nodeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.CLEAR_CONTROL_ERROR, nodeId - }); + }; } export function closeWebsocket() { - AppDispatcher.dispatch({ + return { type: ActionTypes.CLOSE_WEBSOCKET - }); + }; } export function doControl(nodeId, control) { - AppDispatcher.dispatch({ - type: ActionTypes.DO_CONTROL, - nodeId - }); - doControlRequest(nodeId, control); + return (dispatch) => { + dispatch({ + type: ActionTypes.DO_CONTROL, + nodeId + }); + doControlRequest(nodeId, control, dispatch); + }; } export function enterEdge(edgeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.ENTER_EDGE, edgeId - }); + }; } export function enterNode(nodeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.ENTER_NODE, nodeId - }); + }; } export function hitEsc() { - const controlPipe = AppStore.getControlPipe(); - if (AppStore.getShowingHelp()) { - hideHelp(); - } else if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') { - AppDispatcher.dispatch({ - type: ActionTypes.CLICK_CLOSE_TERMINAL, - pipeId: controlPipe.get('id') - }); - updateRoute(); - // Don't deselect node on ESC if there is a controlPipe (keep terminal open) - } else if (AppStore.getTopCardNodeId() && !controlPipe) { - AppDispatcher.dispatch({ type: ActionTypes.DESELECT_NODE }); - updateRoute(); - } + return (dispatch, getState) => { + const state = getState(); + const controlPipe = state.get('controlPipes').last(); + if (state.get('showingHelp')) { + dispatch(hideHelp()); + } else if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') { + dispatch({ + type: ActionTypes.CLICK_CLOSE_TERMINAL, + pipeId: controlPipe.get('id') + }); + updateRoute(getState); + // Don't deselect node on ESC if there is a controlPipe (keep terminal open) + } else if (state.get('nodeDetails').last() && !controlPipe) { + dispatch({ type: ActionTypes.DESELECT_NODE }); + updateRoute(getState); + } + }; } export function leaveEdge(edgeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.LEAVE_EDGE, edgeId - }); + }; } export function leaveNode(nodeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.LEAVE_NODE, nodeId - }); + }; } export function receiveControlError(nodeId, err) { - AppDispatcher.dispatch({ + return { type: ActionTypes.DO_CONTROL_ERROR, nodeId, error: err - }); + }; } export function receiveControlSuccess(nodeId) { - AppDispatcher.dispatch({ + return { type: ActionTypes.DO_CONTROL_SUCCESS, nodeId - }); + }; } export function receiveNodeDetails(details) { - AppDispatcher.dispatch({ + return { type: ActionTypes.RECEIVE_NODE_DETAILS, details - }); + }; } export function receiveNodesDelta(delta) { - if (AppStore.isUpdatePaused()) { - bufferDeltaUpdate(delta); - } else { - AppDispatcher.dispatch({ - type: ActionTypes.RECEIVE_NODES_DELTA, - delta - }); - } + return (dispatch, getState) => { + if (delta.add || delta.update || delta.remove) { + const state = getState(); + if (state.get('updatePausedAt') !== null) { + bufferDeltaUpdate(delta); + } else { + dispatch({ + type: ActionTypes.RECEIVE_NODES_DELTA, + delta + }); + } + } + }; } export function receiveTopologies(topologies) { - AppDispatcher.dispatch({ - type: ActionTypes.RECEIVE_TOPOLOGIES, - topologies - }); - getNodesDelta( - AppStore.getCurrentTopologyUrl(), - AppStore.getActiveTopologyOptions() - ); - getNodeDetails( - AppStore.getTopologyUrlsById(), - AppStore.getNodeDetails() - ); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.RECEIVE_TOPOLOGIES, + topologies + }); + const state = getState(); + getNodesDelta( + getCurrentTopologyUrl(state), + getActiveTopologyOptions(state), + dispatch + ); + getNodeDetails( + state.get('topologyUrlsById'), + state.get('nodeDetails'), + dispatch + ); + }; } export function receiveApiDetails(apiDetails) { - AppDispatcher.dispatch({ + return { type: ActionTypes.RECEIVE_API_DETAILS, hostname: apiDetails.hostname, version: apiDetails.version, plugins: apiDetails.plugins - }); + }; } export function receiveControlNodeRemoved(nodeId) { - AppDispatcher.dispatch({ - type: ActionTypes.RECEIVE_CONTROL_NODE_REMOVED, - nodeId - }); - updateRoute(); + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.RECEIVE_CONTROL_NODE_REMOVED, + nodeId + }); + updateRoute(getState); + }; } export function receiveControlPipeFromParams(pipeId, rawTty) { // TODO add nodeId - AppDispatcher.dispatch({ + return { type: ActionTypes.RECEIVE_CONTROL_PIPE, pipeId, rawTty - }); + }; } export function receiveControlPipe(pipeId, nodeId, rawTty) { - if (nodeId !== AppStore.getTopCardNodeId()) { - log('Node was deselected before we could set up control!'); - deletePipe(pipeId); - return; - } + return (dispatch, getState) => { + const state = getState(); + if (state.get('nodeDetails').last() + && nodeId !== state.get('nodeDetails').last().id) { + log('Node was deselected before we could set up control!'); + deletePipe(pipeId, dispatch); + return; + } - const controlPipe = AppStore.getControlPipe(); - if (controlPipe && controlPipe.get('id') !== pipeId) { - deletePipe(controlPipe.get('id')); - } + const controlPipe = state.get('controlPipes').last(); + if (controlPipe && controlPipe.get('id') !== pipeId) { + deletePipe(controlPipe.get('id'), dispatch); + } - AppDispatcher.dispatch({ - type: ActionTypes.RECEIVE_CONTROL_PIPE, - nodeId, - pipeId, - rawTty - }); + dispatch({ + type: ActionTypes.RECEIVE_CONTROL_PIPE, + nodeId, + pipeId, + rawTty + }); - updateRoute(); + updateRoute(getState); + }; } export function receiveControlPipeStatus(pipeId, status) { - AppDispatcher.dispatch({ + return { type: ActionTypes.RECEIVE_CONTROL_PIPE_STATUS, pipeId, status - }); + }; } export function receiveError(errorUrl) { - AppDispatcher.dispatch({ + return { errorUrl, type: ActionTypes.RECEIVE_ERROR - }); + }; } export function receiveNotFound(nodeId) { - AppDispatcher.dispatch({ + return { nodeId, type: ActionTypes.RECEIVE_NOT_FOUND - }); + }; } -export function route(state) { - AppDispatcher.dispatch({ - state, - type: ActionTypes.ROUTE_TOPOLOGY - }); - getTopologies( - AppStore.getActiveTopologyOptions() - ); - getNodesDelta( - AppStore.getCurrentTopologyUrl(), - AppStore.getActiveTopologyOptions() - ); - getNodeDetails( - AppStore.getTopologyUrlsById(), - AppStore.getNodeDetails() - ); +export function route(urlState) { + return (dispatch, getState) => { + dispatch({ + state: urlState, + type: ActionTypes.ROUTE_TOPOLOGY + }); + // update all request workers with new options + const state = getState(); + getTopologies(getActiveTopologyOptions(state), dispatch); + getNodesDelta( + getCurrentTopologyUrl(state), + getActiveTopologyOptions(state), + dispatch + ); + getNodeDetails( + state.get('topologyUrlsById'), + state.get('nodeDetails'), + dispatch + ); + }; } diff --git a/client/app/scripts/charts/edge-container.js b/client/app/scripts/charts/edge-container.js index d19723070..b0545329d 100644 --- a/client/app/scripts/charts/edge-container.js +++ b/client/app/scripts/charts/edge-container.js @@ -1,9 +1,8 @@ import _ from 'lodash'; import d3 from 'd3'; import React from 'react'; +import { connect } from 'react-redux'; 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'; @@ -29,7 +28,7 @@ const buildPath = (points, layoutPrecision) => { return extracted; }; -export default class EdgeContainer extends React.Component { +class EdgeContainer extends React.Component { constructor(props, context) { super(props, context); @@ -96,4 +95,4 @@ export default class EdgeContainer extends React.Component { } -reactMixin.onClass(EdgeContainer, PureRenderMixin); +export default connect()(EdgeContainer); diff --git a/client/app/scripts/charts/edge.js b/client/app/scripts/charts/edge.js index 8689f6b84..458a8a7c3 100644 --- a/client/app/scripts/charts/edge.js +++ b/client/app/scripts/charts/edge.js @@ -1,11 +1,10 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import classNames from 'classnames'; import { enterEdge, leaveEdge } from '../actions/app-actions'; -export default class Edge extends React.Component { +class Edge extends React.Component { constructor(props, context) { super(props, context); @@ -27,12 +26,15 @@ export default class Edge extends React.Component { } handleMouseEnter(ev) { - enterEdge(ev.currentTarget.id); + this.props.enterEdge(ev.currentTarget.id); } handleMouseLeave(ev) { - leaveEdge(ev.currentTarget.id); + this.props.leaveEdge(ev.currentTarget.id); } } -reactMixin.onClass(Edge, PureRenderMixin); +export default connect( + null, + { enterEdge, leaveEdge } +)(Edge); diff --git a/client/app/scripts/charts/node-container.js b/client/app/scripts/charts/node-container.js index 75506b290..74820303d 100644 --- a/client/app/scripts/charts/node-container.js +++ b/client/app/scripts/charts/node-container.js @@ -1,13 +1,12 @@ import _ from 'lodash'; import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import d3 from 'd3'; import { Motion, spring } from 'react-motion'; import Node from './node'; -export default class NodeContainer extends React.Component { +class NodeContainer extends React.Component { render() { const { dx, dy, focused, layoutPrecision, zoomScale } = this.props; const animConfig = [80, 20]; // stiffness, damping @@ -30,4 +29,4 @@ export default class NodeContainer extends React.Component { } } -reactMixin.onClass(NodeContainer, PureRenderMixin); +export default connect()(NodeContainer); diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index c7469c884..5d96faa41 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -1,7 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import classNames from 'classnames'; import { clickNode, enterNode, leaveNode } from '../actions/app-actions'; @@ -45,7 +44,7 @@ function ellipsis(text, fontSize, maxWidth) { return truncatedText; } -export default class Node extends React.Component { +class Node extends React.Component { constructor(props, context) { super(props, context); @@ -116,18 +115,22 @@ export default class Node extends React.Component { handleMouseClick(ev) { ev.stopPropagation(); - clickNode(this.props.id, this.props.label, ReactDOM.findDOMNode(this).getBoundingClientRect()); + this.props.clickNode(this.props.id, this.props.label, + ReactDOM.findDOMNode(this).getBoundingClientRect()); } handleMouseEnter() { - enterNode(this.props.id); + this.props.enterNode(this.props.id); this.setState({ hovered: true }); } handleMouseLeave() { - leaveNode(this.props.id); + this.props.leaveNode(this.props.id); this.setState({ hovered: false }); } } -reactMixin.onClass(Node, PureRenderMixin); +export default connect( + null, + { clickNode, enterNode, leaveNode } +)(Node); diff --git a/client/app/scripts/charts/nodes-chart-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index 4d1ef1ef5..4fa976288 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -1,10 +1,10 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; +import { hasSelectedNode as hasSelectedNodeFn } from '../utils/topology-utils'; import EdgeContainer from './edge-container'; -export default class NodesChartEdges extends React.Component { +class NodesChartEdges extends React.Component { render() { const {hasSelectedNode, highlightedEdgeIds, layoutEdges, layoutPrecision, selectedNodeId} = this.props; @@ -36,4 +36,14 @@ export default class NodesChartEdges extends React.Component { } } -reactMixin.onClass(NodesChartEdges, PureRenderMixin); +function mapStateToProps(state) { + return { + hasSelectedNode: hasSelectedNodeFn(state), + selectedNodeId: state.get('selectedNodeId'), + highlightedEdgeIds: state.get('highlightedEdgeIds') + }; +} + +export default connect( + mapStateToProps +)(NodesChartEdges); diff --git a/client/app/scripts/charts/nodes-chart-elements.js b/client/app/scripts/charts/nodes-chart-elements.js index 075827818..7d921f0a8 100644 --- a/client/app/scripts/charts/nodes-chart-elements.js +++ b/client/app/scripts/charts/nodes-chart-elements.js @@ -1,31 +1,22 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import NodesChartEdges from './nodes-chart-edges'; import NodesChartNodes from './nodes-chart-nodes'; -export default class NodesChartElements extends React.Component { +class NodesChartElements extends React.Component { render() { const props = this.props; return ( - - + layoutPrecision={props.layoutPrecision} /> ); } } -reactMixin.onClass(NodesChartElements, PureRenderMixin); +export default connect()(NodesChartElements); diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index ce33acec2..b9447e507 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -1,15 +1,15 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { fromJS } from 'immutable'; +import { getAdjacentNodes } from '../utils/topology-utils'; import NodeContainer from './node-container'; -export default class NodesChartNodes extends React.Component { +class NodesChartNodes extends React.Component { render() { - const {adjacentNodes, highlightedNodeIds, - layoutNodes, layoutPrecision, nodeScale, onNodeClick, scale, - selectedMetric, selectedNodeScale, selectedNodeId, topologyId, topCardNode} = this.props; + const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision, + nodeScale, scale, selectedMetric, selectedNodeScale, selectedNodeId, + topologyId, topCardNode } = this.props; const zoomScale = scale; @@ -56,7 +56,6 @@ export default class NodesChartNodes extends React.Component { topologyId={topologyId} shape={node.get('shape')} stack={node.get('stack')} - onClick={onNodeClick} key={node.get('id')} id={node.get('id')} label={node.get('label')} @@ -76,4 +75,17 @@ export default class NodesChartNodes extends React.Component { } } -reactMixin.onClass(NodesChartNodes, PureRenderMixin); +function mapStateToProps(state) { + return { + adjacentNodes: getAdjacentNodes(state), + highlightedNodeIds: state.get('highlightedNodeIds'), + selectedMetric: state.get('selectedMetric'), + selectedNodeId: state.get('selectedNodeId'), + topologyId: state.get('topologyId'), + topCardNode: state.get('nodeDetails').last() + }; +} + +export default connect( + mapStateToProps +)(NodesChartNodes); diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 2d6d36845..9a88e7847 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -2,8 +2,7 @@ import _ from 'lodash'; import d3 from 'd3'; import debug from 'debug'; import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { Map as makeMap, fromJS, is as isDeepEqual } from 'immutable'; import timely from 'timely'; @@ -13,6 +12,8 @@ import { DETAILS_PANEL_WIDTH } from '../constants/styles'; import Logo from '../components/logo'; import { doLayout } from './nodes-layout'; import NodesChartElements from './nodes-chart-elements'; +import { getActiveTopologyOptions, getAdjacentNodes, + isSameTopology } from '../utils/topology-utils'; const log = debug('scope:nodes-chart'); @@ -29,7 +30,7 @@ const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY']; const radiusDensity = d3.scale.threshold() .domain([3, 6]).range([2.5, 3.5, 3]); -export default class NodesChart extends React.Component { +class NodesChart extends React.Component { constructor(props, context) { super(props, context); @@ -88,7 +89,7 @@ export default class NodesChart extends React.Component { state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width); // _.assign(state, this.updateGraphState(nextProps, state)); - if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) { + if (nextProps.forceRelayout || !isSameTopology(nextProps.nodes, this.props.nodes)) { _.assign(state, this.updateGraphState(nextProps, state)); } @@ -139,22 +140,10 @@ export default class NodesChart extends React.Component { - + layoutPrecision={this.props.layoutPrecision} /> ); @@ -162,7 +151,7 @@ export default class NodesChart extends React.Component { handleMouseClick() { if (!this.isZooming) { - clickBackground(); + this.props.clickBackground(); } else { this.isZooming = false; } @@ -411,4 +400,18 @@ export default class NodesChart extends React.Component { } } -reactMixin.onClass(NodesChart, PureRenderMixin); +function mapStateToProps(state) { + return { + adjacentNodes: getAdjacentNodes(state), + forceRelayout: state.get('forceRelayout'), + nodes: state.get('nodes'), + selectedNodeId: state.get('selectedNodeId'), + topologyId: state.get('topologyId'), + topologyOptions: getActiveTopologyOptions(state) + }; +} + +export default connect( + mapStateToProps, + { clickBackground } +)(NodesChart); diff --git a/client/app/scripts/components/__tests__/node-details-test.js b/client/app/scripts/components/__tests__/node-details-test.js index 9a21ecadb..e6be1701c 100644 --- a/client/app/scripts/components/__tests__/node-details-test.js +++ b/client/app/scripts/components/__tests__/node-details-test.js @@ -2,7 +2,6 @@ import React from 'react'; import Immutable from 'immutable'; import TestUtils from 'react/lib/ReactTestUtils'; -jest.dontMock('../../dispatcher/app-dispatcher'); jest.dontMock('../node-details.js'); jest.dontMock('../node-details/node-details-controls.js'); jest.dontMock('../node-details/node-details-relatives.js'); @@ -13,7 +12,7 @@ jest.dontMock('../../utils/color-utils'); jest.dontMock('../../utils/title-utils'); // need ES5 require to keep automocking off -const NodeDetails = require('../node-details.js').default; +const NodeDetails = require('../node-details.js').NodeDetails; describe('NodeDetails', () => { let nodes; diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 93f4e5cc1..d9cd7045f 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -1,10 +1,8 @@ import debug from 'debug'; import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import Logo from './logo'; -import AppStore from '../stores/app-store'; import Footer from './footer.js'; import Sidebar from './sidebar.js'; import HelpPanel from './help-panel'; @@ -19,67 +17,32 @@ import Nodes from './nodes'; import MetricSelector from './metric-selector'; import EmbeddedTerminal from './embedded-terminal'; import { getRouter } from '../utils/router-utils'; -import { showingDebugToolbar, toggleDebugToolbar, - DebugToolbar } from './debug-toolbar.js'; +import DebugToolbar, { showingDebugToolbar, + toggleDebugToolbar } from './debug-toolbar.js'; +import { getUrlState } from '../utils/router-utils'; +import { getActiveTopologyOptions } from '../utils/topology-utils'; const ESC_KEY_CODE = 27; 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(), - currentTopologyId: AppStore.getCurrentTopologyId(), - currentTopologyOptions: AppStore.getCurrentTopologyOptions(), - errorUrl: AppStore.getErrorUrl(), - forceRelayout: AppStore.isForceRelayout(), - highlightedEdgeIds: AppStore.getHighlightedEdgeIds(), - highlightedNodeIds: AppStore.getHighlightedNodeIds(), - hostname: AppStore.getHostname(), - pinnedMetric: AppStore.getPinnedMetric(), - availableCanvasMetrics: AppStore.getAvailableCanvasMetrics(), - nodeDetails: AppStore.getNodeDetails(), - nodes: AppStore.getNodes(), - showingHelp: AppStore.getShowingHelp(), - selectedNodeId: AppStore.getSelectedNodeId(), - selectedMetric: AppStore.getSelectedMetric(), - topologies: AppStore.getTopologies(), - topologiesLoaded: AppStore.isTopologiesLoaded(), - topologyEmpty: AppStore.isTopologyEmpty(), - updatePaused: AppStore.isUpdatePaused(), - updatePausedAt: AppStore.getUpdatePausedAt(), - version: AppStore.getVersion(), - versionUpdate: AppStore.getVersionUpdate(), - plugins: AppStore.getPlugins(), - websocketClosed: AppStore.isWebsocketClosed() - }; -} - -export default class App extends React.Component { +class App extends React.Component { constructor(props, context) { super(props, context); - this.onChange = this.onChange.bind(this); this.onKeyPress = this.onKeyPress.bind(this); this.onKeyUp = this.onKeyUp.bind(this); - this.state = getStateFromStores(); } componentDidMount() { - AppStore.addListener(this.onChange); window.addEventListener('keypress', this.onKeyPress); window.addEventListener('keyup', this.onKeyUp); - getRouter().start({hashbang: true}); - if (!AppStore.isRouteSet()) { + getRouter(this.props.dispatch, this.props.urlState).start({hashbang: true}); + if (!this.props.routeSet) { // dont request topologies when already done via router - getTopologies(AppStore.getActiveTopologyOptions()); + getTopologies(this.props.activeTopologyOptions, this.props.dispatch); } - getApiDetails(); + getApiDetails(this.props.dispatch); } componentWillUnmount() { @@ -87,18 +50,15 @@ export default class App extends React.Component { window.removeEventListener('keyup', this.onKeyUp); } - onChange() { - this.setState(getStateFromStores()); - } - onKeyUp(ev) { // don't get esc in onKeyPress if (ev.keyCode === ESC_KEY_CODE) { - hitEsc(); + this.props.dispatch(hitEsc()); } } onKeyPress(ev) { + const { dispatch } = this.props; // // keyup gives 'key' // keypress gives 'char' @@ -108,42 +68,35 @@ export default class App extends React.Component { keyPressLog('onKeyPress', 'keyCode', ev.keyCode, ev); const char = String.fromCharCode(ev.charCode); if (char === '<') { - pinNextMetric(-1); + dispatch(pinNextMetric(-1)); } else if (char === '>') { - pinNextMetric(1); + dispatch(pinNextMetric(1)); } else if (char === 'q') { - unpinMetric(); - selectMetric(null); + dispatch(unpinMetric()); + dispatch(selectMetric(null)); } else if (char === 'd') { toggleDebugToolbar(); this.forceUpdate(); } else if (char === '?') { - toggleHelp(); + dispatch(toggleHelp()); } } render() { - const { nodeDetails, controlPipe } = this.state; - const topCardNode = nodeDetails.last(); + const { availableCanvasMetrics, nodeDetails, controlPipes, showingHelp } = this.props; const showingDetails = nodeDetails.size > 0; - const showingTerminal = controlPipe; - // width of details panel blocking a view - const detailsWidth = showingDetails ? 450 : 0; - const topMargin = 100; + const showingTerminal = controlPipes.size > 0; + const showingMetricsSelector = availableCanvasMetrics.count() > 0; return (
{showingDebugToolbar() && } - {this.state.showingHelp && } + {showingHelp && } - {showingDetails &&
} + {showingDetails &&
} - {showingTerminal && } + {showingTerminal && }
@@ -151,45 +104,35 @@ export default class App extends React.Component {
- +
- + - - {this.state.availableCanvasMetrics.count() > 0 && } - + + {showingMetricsSelector && } + -
+
); } } -reactMixin.onClass(App, PureRenderMixin); +function mapStateToProps(state) { + return { + activeTopologyOptions: getActiveTopologyOptions(state), + availableCanvasMetrics: state.get('availableCanvasMetrics'), + controlPipes: state.get('controlPipes'), + nodeDetails: state.get('nodeDetails'), + routeSet: state.get('routeSet'), + showingHelp: state.get('showingHelp'), + urlState: getUrlState(state) + }; +} + +export default connect( + mapStateToProps +)(App); diff --git a/client/app/scripts/components/debug-toolbar.js b/client/app/scripts/components/debug-toolbar.js index ef400ec65..214f1a1e3 100644 --- a/client/app/scripts/components/debug-toolbar.js +++ b/client/app/scripts/components/debug-toolbar.js @@ -2,12 +2,13 @@ import React from 'react'; import _ from 'lodash'; import Perf from 'react-addons-perf'; +import { connect } from 'react-redux'; +import { fromJS } from 'immutable'; import debug from 'debug'; const log = debug('scope:debug-panel'); import { receiveNodesDelta } from '../actions/app-actions'; -import AppStore from '../stores/app-store'; import { getNodeColor, getNodeColorDark } from '../utils/color-utils'; @@ -56,11 +57,10 @@ const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCou }); -function addMetrics(node, v) { - const availableMetrics = AppStore.getAvailableCanvasMetrics().toJS(); - const metrics = availableMetrics.length > 0 ? availableMetrics : [ +function addMetrics(availableMetrics, node, v) { + const metrics = availableMetrics.size > 0 ? availableMetrics : fromJS([ {id: 'host_cpu_usage_percent', label: 'CPU'} - ]; + ]); return Object.assign({}, node, { metrics: metrics.map(m => Object.assign({}, m, {max: 100, value: v})) @@ -74,26 +74,26 @@ function label(shape, stacked) { } -function addAllVariants() { +function addAllVariants(dispatch) { const newNodes = _.flattenDeep(STACK_VARIANTS.map(stack => (SHAPES.map(s => { if (!stack) return [deltaAdd(label(s, stack), [], s, stack, 1)]; return NODE_COUNTS.map(n => deltaAdd(label(s, stack), [], s, stack, n)); })))); - receiveNodesDelta({ + dispatch(receiveNodesDelta({ add: newNodes - }); + })); } -function addAllMetricVariants() { +function addAllMetricVariants(availableMetrics, dispatch) { const newNodes = _.flattenDeep(METRIC_FILLS.map((v, i) => ( - SHAPES.map(s => [addMetrics(deltaAdd(label(s) + i, [], s), v)]) + SHAPES.map(s => [addMetrics(availableMetrics, deltaAdd(label(s) + i, [], s), v)]) ))); - receiveNodesDelta({ + dispatch(receiveNodesDelta({ add: newNodes - }); + })); } @@ -109,27 +109,6 @@ function startPerf(delay) { setTimeout(stopPerf, delay * 1000); } - -function addNodes(n, prefix = 'zing') { - const ns = AppStore.getNodes(); - const nodeNames = ns.keySeq().toJS(); - const newNodeNames = _.range(ns.size, ns.size + n).map(i => ( - // `${randomLetter()}${randomLetter()}-zing` - `${prefix}${i}` - )); - const allNodes = _(nodeNames).concat(newNodeNames).value(); - - receiveNodesDelta({ - add: newNodeNames.map((name) => deltaAdd( - name, - sample(allNodes), - _.sample(SHAPES), - _.sample(STACK_VARIANTS), - _.sample(NODE_COUNTS) - )) - }); -} - export function showingDebugToolbar() { return (('debugToolbar' in localStorage && JSON.parse(localStorage.debugToolbar)) || location.pathname.indexOf('debug') > -1); @@ -153,12 +132,13 @@ function disableLog() { window.location.reload(); } -export class DebugToolbar extends React.Component { +class DebugToolbar extends React.Component { constructor(props, context) { super(props, context); this.onChange = this.onChange.bind(this); this.toggleColors = this.toggleColors.bind(this); + this.addNodes = this.addNodes.bind(this); this.state = { nodesToAdd: 30, showColors: false @@ -175,20 +155,44 @@ export class DebugToolbar extends React.Component { }); } + addNodes(n, prefix = 'zing') { + const ns = this.props.nodes; + const nodeNames = ns.keySeq().toJS(); + const newNodeNames = _.range(ns.size, ns.size + n).map(i => ( + // `${randomLetter()}${randomLetter()}-zing` + `${prefix}${i}` + )); + const allNodes = _(nodeNames).concat(newNodeNames).value(); + + this.props.dispatch(receiveNodesDelta({ + add: newNodeNames.map((name) => deltaAdd( + name, + sample(allNodes), + _.sample(SHAPES), + _.sample(STACK_VARIANTS), + _.sample(NODE_COUNTS) + )) + })); + + log('added nodes', n); + } + render() { - log('rending debug panel'); + const { availableCanvasMetrics } = this.props; return (
- - + + - - - - + + + +
@@ -228,3 +232,14 @@ export class DebugToolbar extends React.Component { ); } } + +function mapStateToProps(state) { + return { + nodes: state.get('nodes'), + availableCanvasMetrics: state.get('availableCanvasMetrics') + }; +} + +export default connect( + mapStateToProps +)(DebugToolbar); diff --git a/client/app/scripts/components/details-card.js b/client/app/scripts/components/details-card.js index bfee43e9a..a8469625d 100644 --- a/client/app/scripts/components/details-card.js +++ b/client/app/scripts/components/details-card.js @@ -1,12 +1,11 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import NodeDetails from './node-details'; import { DETAILS_PANEL_WIDTH as WIDTH, DETAILS_PANEL_OFFSET as OFFSET, DETAILS_PANEL_MARGINS as MARGINS } from '../constants/styles'; -export default class DetailsCard extends React.Component { +class DetailsCard extends React.Component { constructor(props, context) { super(props, context); @@ -54,4 +53,4 @@ export default class DetailsCard extends React.Component { } } -reactMixin.onClass(DetailsCard, PureRenderMixin); +export default connect()(DetailsCard); diff --git a/client/app/scripts/components/details.js b/client/app/scripts/components/details.js index 528e2790b..87938a052 100644 --- a/client/app/scripts/components/details.js +++ b/client/app/scripts/components/details.js @@ -1,15 +1,30 @@ import React from 'react'; +import { connect } from 'react-redux'; import DetailsCard from './details-card'; -export default function Details({controlStatus, details, nodes}) { - // render all details as cards, later cards go on top - return ( -
- {details.toIndexedSeq().map((obj, index) => - )} -
- ); +class Details extends React.Component { + render() { + const { controlStatus, details } = this.props; + // render all details as cards, later cards go on top + return ( +
+ {details.toIndexedSeq().map((obj, index) => + )} +
+ ); + } } + +function mapStateToProps(state) { + return { + controlStatus: state.get('controlStatus'), + details: state.get('nodeDetails') + }; +} + +export default connect( + mapStateToProps +)(Details); diff --git a/client/app/scripts/components/dev-tools.js b/client/app/scripts/components/dev-tools.js new file mode 100644 index 000000000..3012ab99b --- /dev/null +++ b/client/app/scripts/components/dev-tools.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { createDevTools } from 'redux-devtools'; +import LogMonitor from 'redux-devtools-log-monitor'; +import DockMonitor from 'redux-devtools-dock-monitor'; + +export default createDevTools( + + + +); diff --git a/client/app/scripts/components/embedded-terminal.js b/client/app/scripts/components/embedded-terminal.js index 524bc9e98..2ad699449 100644 --- a/client/app/scripts/components/embedded-terminal.js +++ b/client/app/scripts/components/embedded-terminal.js @@ -1,29 +1,45 @@ import React from 'react'; +import { connect } from 'react-redux'; import { getNodeColor, getNodeColorDark } from '../utils/color-utils'; import Terminal from './terminal'; import { DETAILS_PANEL_WIDTH, DETAILS_PANEL_MARGINS, DETAILS_PANEL_OFFSET } from '../constants/styles'; -export default function EmeddedTerminal({pipe, details}) { - const nodeId = pipe.get('nodeId'); - const node = details.get(nodeId); - const d = node && node.details; - const titleBarColor = d && getNodeColorDark(d.rank, d.label); - const statusBarColor = d && getNodeColor(d.rank, d.label); - const title = d && d.label; +class EmeddedTerminal extends React.Component { + render() { + const { pipe, details } = this.props; + const nodeId = pipe.get('nodeId'); + const node = details.get(nodeId); + const d = node && node.details; + const titleBarColor = d && getNodeColorDark(d.rank, d.label); + const statusBarColor = d && getNodeColor(d.rank, d.label); + const title = d && d.label; - const style = { - right: DETAILS_PANEL_MARGINS.right + DETAILS_PANEL_WIDTH + 10 + - (details.size * DETAILS_PANEL_OFFSET) - }; + const style = { + right: DETAILS_PANEL_MARGINS.right + DETAILS_PANEL_WIDTH + 10 + + (details.size * DETAILS_PANEL_OFFSET) + }; - // React unmount/remounts when key changes, this is important for cleaning up - // the term.js and creating a new one for the new pipe. - return ( -
- -
- ); + // React unmount/remounts when key changes, this is important for cleaning up + // the term.js and creating a new one for the new pipe. + return ( +
+ +
+ ); + } } + +function mapStateToProps(state) { + return { + details: state.get('nodeDetails'), + pipe: state.get('controlPipes').last() + }; +} + +export default connect( + mapStateToProps +)(EmeddedTerminal); diff --git a/client/app/scripts/components/footer.js b/client/app/scripts/components/footer.js index 48bfedd7e..6b5b37aee 100644 --- a/client/app/scripts/components/footer.js +++ b/client/app/scripts/components/footer.js @@ -1,4 +1,5 @@ import React from 'react'; +import { connect } from 'react-redux'; import moment from 'moment'; import Plugins from './plugins.js'; @@ -8,83 +9,103 @@ import { clickDownloadGraph, clickForceRelayout, clickPauseUpdate, clickResumeUpdate, toggleHelp } from '../actions/app-actions'; import { basePathSlash } from '../utils/web-api-utils'; -export default function Footer(props) { - const { hostname, plugins, updatePaused, updatePausedAt, version, versionUpdate } = props; - const contrastMode = isContrastMode(); +class Footer extends React.Component { + render() { + const { hostname, updatePausedAt, version, versionUpdate } = this.props; + const contrastMode = isContrastMode(); - // link url to switch contrast with current UI state - const otherContrastModeUrl = contrastMode - ? basePathSlash(window.location.pathname) : contrastModeUrl; - const otherContrastModeTitle = contrastMode - ? 'Switch to normal contrast' : 'Switch to high contrast'; - const forceRelayoutTitle = 'Force re-layout (might reduce edge crossings, ' - + 'but may shift nodes around)'; + // link url to switch contrast with current UI state + const otherContrastModeUrl = contrastMode + ? basePathSlash(window.location.pathname) : contrastModeUrl; + const otherContrastModeTitle = contrastMode + ? 'Switch to normal contrast' : 'Switch to high contrast'; + const forceRelayoutTitle = 'Force re-layout (might reduce edge crossings, ' + + 'but may shift nodes around)'; - // pause button - const isPaused = updatePaused; - const updateCount = getUpdateBufferSize(); - const hasUpdates = updateCount > 0; - const pausedAgo = moment(updatePausedAt).fromNow(); - const pauseTitle = isPaused - ? `Paused ${pausedAgo}` : 'Pause updates (freezes the nodes in their current layout)'; - const pauseAction = isPaused ? clickResumeUpdate : clickPauseUpdate; - const pauseClassName = isPaused ? 'footer-icon footer-icon-active' : 'footer-icon'; - let pauseLabel = ''; - if (hasUpdates && isPaused) { - pauseLabel = `Paused +${updateCount}`; - } else if (hasUpdates && !isPaused) { - pauseLabel = `Resuming +${updateCount}`; - } else if (!hasUpdates && isPaused) { - pauseLabel = 'Paused'; + // pause button + const isPaused = updatePausedAt !== null; + const updateCount = getUpdateBufferSize(); + const hasUpdates = updateCount > 0; + const pausedAgo = moment(updatePausedAt).fromNow(); + const pauseTitle = isPaused + ? `Paused ${pausedAgo}` : 'Pause updates (freezes the nodes in their current layout)'; + const pauseAction = isPaused ? this.props.clickResumeUpdate : this.props.clickPauseUpdate; + const pauseClassName = isPaused ? 'footer-icon footer-icon-active' : 'footer-icon'; + let pauseLabel = ''; + if (hasUpdates && isPaused) { + pauseLabel = `Paused +${updateCount}`; + } else if (hasUpdates && !isPaused) { + pauseLabel = `Resuming +${updateCount}`; + } else if (!hasUpdates && isPaused) { + pauseLabel = 'Paused'; + } + + const versionUpdateTitle = versionUpdate + ? `New version available: ${versionUpdate.version}. Click to download` + : ''; + + return ( +
+ +
+ {versionUpdate && + Update available: {versionUpdate.version} + } + Version + {version} + on + {hostname} +
+ +
+ +
+ +
+ + {pauseLabel !== '' && {pauseLabel}} + + + + + + + + + + + + + + + + + + + + +
+ +
+ ); } - - const versionUpdateTitle = versionUpdate - ? `New version available: ${versionUpdate.version}. Click to download` - : ''; - - return ( -
- -
- {versionUpdate && - Update available: {versionUpdate.version} - } - Version - {version} - on - {hostname} -
- -
- -
- -
- - {pauseLabel !== '' && {pauseLabel}} - - - - - - - - - - - - - - - - - - - - -
- -
- ); } + +function mapStateToProps(state) { + return { + hostname: state.get('hostname'), + updatePausedAt: state.get('updatePausedAt'), + version: state.get('version'), + versionUpdate: state.get('versionUpdate') + }; +} + +export default connect( + mapStateToProps, + { clickDownloadGraph, clickForceRelayout, clickPauseUpdate, + clickResumeUpdate, toggleHelp } +)(Footer); diff --git a/client/app/scripts/components/metric-selector-item.js b/client/app/scripts/components/metric-selector-item.js index d3890e0a4..9c6e4481c 100644 --- a/client/app/scripts/components/metric-selector-item.js +++ b/client/app/scripts/components/metric-selector-item.js @@ -1,9 +1,10 @@ import React from 'react'; import classNames from 'classnames'; +import { connect } from 'react-redux'; + import { selectMetric, pinMetric, unpinMetric } from '../actions/app-actions'; - -export class MetricSelectorItem extends React.Component { +class MetricSelectorItem extends React.Component { constructor(props, context) { super(props, context); @@ -14,7 +15,7 @@ export class MetricSelectorItem extends React.Component { onMouseOver() { const k = this.props.metric.get('id'); - selectMetric(k); + this.props.selectMetric(k); } onMouseClick() { @@ -22,9 +23,9 @@ export class MetricSelectorItem extends React.Component { const pinnedMetric = this.props.pinnedMetric; if (k === pinnedMetric) { - unpinMetric(k); + this.props.unpinMetric(k); } else { - pinMetric(k); + this.props.pinMetric(k); } } @@ -49,3 +50,15 @@ export class MetricSelectorItem extends React.Component { ); } } + +function mapStateToProps(state) { + return { + selectedMetric: state.get('selectedMetric'), + pinnedMetric: state.get('pinnedMetric') + }; +} + +export default connect( + mapStateToProps, + { selectMetric, pinMetric, unpinMetric } +)(MetricSelectorItem); diff --git a/client/app/scripts/components/metric-selector.js b/client/app/scripts/components/metric-selector.js index a00578e33..daa2f5414 100644 --- a/client/app/scripts/components/metric-selector.js +++ b/client/app/scripts/components/metric-selector.js @@ -1,9 +1,10 @@ import React from 'react'; +import { connect } from 'react-redux'; + import { selectMetric } from '../actions/app-actions'; -import { MetricSelectorItem } from './metric-selector-item'; +import MetricSelectorItem from './metric-selector-item'; - -export default class MetricSelector extends React.Component { +class MetricSelector extends React.Component { constructor(props, context) { super(props, context); @@ -11,19 +12,18 @@ export default class MetricSelector extends React.Component { } onMouseOut() { - selectMetric(this.props.pinnedMetric); + this.props.selectMetric(this.props.pinnedMetric); } render() { const {availableCanvasMetrics} = this.props; const items = availableCanvasMetrics.map(metric => ( - + )); return ( -
+
{items}
@@ -32,3 +32,14 @@ export default class MetricSelector extends React.Component { } } +function mapStateToProps(state) { + return { + availableCanvasMetrics: state.get('availableCanvasMetrics'), + pinnedMetric: state.get('pinnedMetric') + }; +} + +export default connect( + mapStateToProps, + { selectMetric } +)(MetricSelector); diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index bf8cd1506..6c566c372 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -1,5 +1,6 @@ import _ from 'lodash'; import React from 'react'; +import { connect } from 'react-redux'; import NodeDetailsControls from './node-details/node-details-controls'; import NodeDetailsHealth from './node-details/node-details-health'; @@ -11,7 +12,7 @@ import { clickCloseDetails, clickShowTopologyForNode } from '../actions/app-acti import { brightenColor, getNeutralColor, getNodeColorDark } from '../utils/color-utils'; import { resetDocumentTitle, setDocumentTitle } from '../utils/title-utils'; -export default class NodeDetails extends React.Component { +export class NodeDetails extends React.Component { constructor(props, context) { super(props, context); @@ -21,12 +22,12 @@ export default class NodeDetails extends React.Component { handleClickClose(ev) { ev.preventDefault(); - clickCloseDetails(this.props.nodeId); + this.props.clickCloseDetails(this.props.nodeId); } handleShowTopologyForNode(ev) { ev.preventDefault(); - clickShowTopologyForNode(this.props.topologyId, this.props.nodeId); + this.props.clickShowTopologyForNode(this.props.topologyId, this.props.nodeId); } componentDidMount() { @@ -215,3 +216,14 @@ export default class NodeDetails extends React.Component { setDocumentTitle(this.props.details && this.props.details.label); } } + +function mapStateToProps(state) { + return { + nodes: state.get('nodes') + }; +} + +export default connect( + mapStateToProps, + { clickCloseDetails, clickShowTopologyForNode } +)(NodeDetails); diff --git a/client/app/scripts/components/node-details/node-details-control-button.js b/client/app/scripts/components/node-details/node-details-control-button.js index 99a2629a6..36c414ae6 100644 --- a/client/app/scripts/components/node-details/node-details-control-button.js +++ b/client/app/scripts/components/node-details/node-details-control-button.js @@ -1,8 +1,9 @@ import React from 'react'; +import { connect } from 'react-redux'; import { doControl } from '../../actions/app-actions'; -export default class NodeDetailsControlButton extends React.Component { +class NodeDetailsControlButton extends React.Component { constructor(props, context) { super(props, context); this.handleClick = this.handleClick.bind(this); @@ -20,6 +21,8 @@ export default class NodeDetailsControlButton extends React.Component { handleClick(ev) { ev.preventDefault(); - doControl(this.props.nodeId, this.props.control); + this.props.dispatch(doControl(this.props.nodeId, this.props.control)); } } + +export default connect()(NodeDetailsControlButton); 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 50a19b24f..81c9d43d7 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,11 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { clickRelative } from '../../actions/app-actions'; -export default class NodeDetailsRelativesLink extends React.Component { +class NodeDetailsRelativesLink extends React.Component { constructor(props, context) { super(props, context); @@ -14,8 +13,8 @@ export default class NodeDetailsRelativesLink extends React.Component { handleClick(ev) { ev.preventDefault(); - clickRelative(this.props.id, this.props.topologyId, this.props.label, - ReactDOM.findDOMNode(this).getBoundingClientRect()); + this.props.dispatch(clickRelative(this.props.id, this.props.topologyId, + this.props.label, ReactDOM.findDOMNode(this).getBoundingClientRect())); } render() { @@ -28,4 +27,4 @@ export default class NodeDetailsRelativesLink extends React.Component { } } -reactMixin.onClass(NodeDetailsRelativesLink, PureRenderMixin); +export default connect()(NodeDetailsRelativesLink); 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 b463a7e00..3c54af6ab 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,11 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { clickRelative } from '../../actions/app-actions'; -export default class NodeDetailsTableNodeLink extends React.Component { +class NodeDetailsTableNodeLink extends React.Component { constructor(props, context) { super(props, context); @@ -14,8 +13,8 @@ export default class NodeDetailsTableNodeLink extends React.Component { handleClick(ev) { ev.preventDefault(); - clickRelative(this.props.nodeId, this.props.topologyId, this.props.label, - ReactDOM.findDOMNode(this).getBoundingClientRect()); + this.props.dispatch(clickRelative(this.props.nodeId, this.props.topologyId, + this.props.label, ReactDOM.findDOMNode(this).getBoundingClientRect())); } render() { @@ -35,4 +34,4 @@ export default class NodeDetailsTableNodeLink extends React.Component { } } -reactMixin.onClass(NodeDetailsTableNodeLink, PureRenderMixin); +export default connect()(NodeDetailsTableNodeLink); diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 28d3ae79d..d2ce010be 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -1,12 +1,13 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import NodesChart from '../charts/nodes-chart'; import NodesError from '../charts/nodes-error'; +import { isTopologyEmpty } from '../utils/topology-utils'; const navbarHeight = 160; const marginTop = 0; +const detailsWidth = 450; /** * dynamic coords precision based on topology size @@ -26,7 +27,7 @@ function getLayoutPrecision(nodesCount) { return precision; } -export default class Nodes extends React.Component { +class Nodes extends React.Component { constructor(props, context) { super(props, context); this.handleResize = this.handleResize.bind(this); @@ -70,7 +71,8 @@ export default class Nodes extends React.Component { return (
{topologyEmpty && errorEmpty} - @@ -90,4 +92,14 @@ export default class Nodes extends React.Component { } } -reactMixin.onClass(Nodes, PureRenderMixin); +function mapStateToProps(state) { + return { + nodes: state.get('nodes'), + selectedNodeId: state.get('selectedNodeId'), + topologyEmpty: isTopologyEmpty(state), + }; +} + +export default connect( + mapStateToProps +)(Nodes); diff --git a/client/app/scripts/components/plugins.js b/client/app/scripts/components/plugins.js index f7b191682..5217b1ed0 100644 --- a/client/app/scripts/components/plugins.js +++ b/client/app/scripts/components/plugins.js @@ -1,7 +1,8 @@ import React from 'react'; +import { connect } from 'react-redux'; import classNames from 'classnames'; -export default class Plugins extends React.Component { +class Plugins extends React.Component { renderPlugin({id, label, description, status}) { const error = status !== 'ok'; const className = classNames({ error }); @@ -19,15 +20,26 @@ export default class Plugins extends React.Component { } render() { - const hasPlugins = this.props.plugins && this.props.plugins.length > 0; + const hasPlugins = this.props.plugins && this.props.plugins.size > 0; return (
Plugins: - {hasPlugins && this.props.plugins.map((plugin, index) => this.renderPlugin(plugin, index))} + {hasPlugins && this.props.plugins.toIndexedSeq() + .map((plugin, index) => this.renderPlugin(plugin, index))} {!hasPlugins && n/a}
); } } + +function mapStateToProps(state) { + return { + plugins: state.get('plugins') + }; +} + +export default connect( + mapStateToProps +)(Plugins); diff --git a/client/app/scripts/components/show-more.js b/client/app/scripts/components/show-more.js index a9d17bff6..5578cf5ff 100644 --- a/client/app/scripts/components/show-more.js +++ b/client/app/scripts/components/show-more.js @@ -1,8 +1,7 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; -export default class ShowMore extends React.Component { +class ShowMore extends React.Component { constructor(props, context) { super(props, context); @@ -31,4 +30,4 @@ export default class ShowMore extends React.Component { } } -reactMixin.onClass(ShowMore, PureRenderMixin); +export default connect()(ShowMore); diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index c2a027391..b22e190f7 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -1,36 +1,54 @@ import React from 'react'; +import { connect } from 'react-redux'; -export default function Status({errorUrl, topologiesLoaded, topology, websocketClosed}) { - let title = ''; - let text = 'Trying to reconnect...'; - let showWarningIcon = false; - let classNames = 'status sidebar-item'; +class Status extends React.Component { + render() { + const {errorUrl, topologiesLoaded, topology, websocketClosed} = this.props; - if (errorUrl) { - title = `Cannot reach Scope. Make sure the following URL is reachable: ${errorUrl}`; - classNames += ' status-loading'; - showWarningIcon = true; - } else if (!topologiesLoaded) { - text = 'Connecting to Scope...'; - classNames += ' status-loading'; - showWarningIcon = true; - } else if (websocketClosed) { - classNames += ' status-loading'; - showWarningIcon = true; - } else if (topology) { - const stats = topology.get('stats'); - text = `${stats.get('node_count')} nodes`; - if (stats.get('filtered_nodes')) { - text = `${text} (${stats.get('filtered_nodes')} filtered)`; + let title = ''; + let text = 'Trying to reconnect...'; + let showWarningIcon = false; + let classNames = 'status sidebar-item'; + + if (errorUrl) { + title = `Cannot reach Scope. Make sure the following URL is reachable: ${errorUrl}`; + classNames += ' status-loading'; + showWarningIcon = true; + } else if (!topologiesLoaded) { + text = 'Connecting to Scope...'; + classNames += ' status-loading'; + showWarningIcon = true; + } else if (websocketClosed) { + classNames += ' status-loading'; + showWarningIcon = true; + } else if (topology) { + const stats = topology.get('stats'); + text = `${stats.get('node_count')} nodes`; + if (stats.get('filtered_nodes')) { + text = `${text} (${stats.get('filtered_nodes')} filtered)`; + } + classNames += ' status-stats'; + showWarningIcon = false; } - classNames += ' status-stats'; - showWarningIcon = false; - } - return ( -
- {showWarningIcon && } - {text} -
- ); + return ( +
+ {showWarningIcon && } + {text} +
+ ); + } } + +function mapStateToProps(state) { + return { + errorUrl: state.get('errorUrl'), + topologiesLoaded: state.get('topologiesLoaded'), + topology: state.get('currentTopology'), + websocketClosed: state.get('websocketClosed') + }; +} + +export default connect( + mapStateToProps +)(Status); diff --git a/client/app/scripts/components/terminal-app.js b/client/app/scripts/components/terminal-app.js index b9e348515..b48da142d 100644 --- a/client/app/scripts/components/terminal-app.js +++ b/client/app/scripts/components/terminal-app.js @@ -1,48 +1,32 @@ import React from 'react'; +import { connect } from 'react-redux'; -import AppStore from '../stores/app-store'; import Terminal from './terminal'; import { receiveControlPipeFromParams } from '../actions/app-actions'; -function getStateFromStores() { - return { - controlPipe: AppStore.getControlPipe() - }; -} - -export class TerminalApp extends React.Component { +class TerminalApp extends React.Component { constructor(props, context) { super(props, context); - this.onChange = this.onChange.bind(this); const paramString = window.location.hash.split('/').pop(); const params = JSON.parse(decodeURIComponent(paramString)); - receiveControlPipeFromParams(params.pipe.id, null, params.pipe.raw, false); + this.props.receiveControlPipeFromParams(params.pipe.id, null, params.pipe.raw, false); this.state = { title: params.title, titleBarColor: params.titleBarColor, - statusBarColor: params.statusBarColor, - controlPipe: AppStore.getControlPipe() + statusBarColor: params.statusBarColor }; } - componentDidMount() { - AppStore.addListener(this.onChange); - } - - onChange() { - this.setState(getStateFromStores()); - } - render() { const style = {borderTop: `4px solid ${this.state.titleBarColor}`}; return (
- {this.state.controlPipe && { clearTimeout(this.reconnectTimeout); @@ -210,7 +211,7 @@ export default class Terminal extends React.Component { handleCloseClick(ev) { ev.preventDefault(); if (this.isEmbedded()) { - clickCloseTerminal(this.getPipeId(), true); + this.props.dispatch(clickCloseTerminal(this.getPipeId(), true)); } else { window.close(); } @@ -219,7 +220,7 @@ export default class Terminal extends React.Component { handlePopoutTerminal(ev) { ev.preventDefault(); const paramString = JSON.stringify(this.props); - clickCloseTerminal(this.getPipeId()); + this.props.dispatch(clickCloseTerminal(this.getPipeId())); const bcr = ReactDOM.findDOMNode(this).getBoundingClientRect(); const minWidth = this.state.pixelPerCol * 80 + (8 * 2); @@ -322,3 +323,5 @@ export default class Terminal extends React.Component { ); } } + +export default connect()(Terminal); diff --git a/client/app/scripts/components/topologies.js b/client/app/scripts/components/topologies.js index d8152f412..84d8b75e9 100644 --- a/client/app/scripts/components/topologies.js +++ b/client/app/scripts/components/topologies.js @@ -1,20 +1,18 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { clickTopology } from '../actions/app-actions'; -export default class Topologies extends React.Component { +class Topologies extends React.Component { constructor(props, context) { super(props, context); this.onTopologyClick = this.onTopologyClick.bind(this); - this.renderSubTopology = this.renderSubTopology.bind(this); } onTopologyClick(ev) { ev.preventDefault(); - clickTopology(ev.currentTarget.getAttribute('rel')); + this.props.clickTopology(ev.currentTarget.getAttribute('rel')); } renderSubTopology(subTopology) { @@ -55,7 +53,7 @@ export default class Topologies extends React.Component {
{topology.has('sub_topologies') - && topology.get('sub_topologies').map(this.renderSubTopology)} + && topology.get('sub_topologies').map(subTop => this.renderSubTopology(subTop))}
); @@ -72,4 +70,14 @@ export default class Topologies extends React.Component { } } -reactMixin.onClass(Topologies, PureRenderMixin); +function mapStateToProps(state) { + return { + topologies: state.get('topologies'), + currentTopology: state.get('currentTopology') + }; +} + +export default connect( + mapStateToProps, + { clickTopology } +)(Topologies); diff --git a/client/app/scripts/components/topology-option-action.js b/client/app/scripts/components/topology-option-action.js index b83eeabe8..f8ee59770 100644 --- a/client/app/scripts/components/topology-option-action.js +++ b/client/app/scripts/components/topology-option-action.js @@ -1,10 +1,9 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; import { changeTopologyOption } from '../actions/app-actions'; -export default class TopologyOptionAction extends React.Component { +class TopologyOptionAction extends React.Component { constructor(props, context) { super(props, context); @@ -14,7 +13,7 @@ export default class TopologyOptionAction extends React.Component { onClick(ev) { ev.preventDefault(); const { optionId, topologyId, item } = this.props; - changeTopologyOption(optionId, item.get('value'), topologyId); + this.props.changeTopologyOption(optionId, item.get('value'), topologyId); } render() { @@ -29,4 +28,7 @@ export default class TopologyOptionAction extends React.Component { } } -reactMixin.onClass(TopologyOptionAction, PureRenderMixin); +export default connect( + null, + { changeTopologyOption } +)(TopologyOptionAction); diff --git a/client/app/scripts/components/topology-options.js b/client/app/scripts/components/topology-options.js index 758cda37a..89bf4ccc9 100644 --- a/client/app/scripts/components/topology-options.js +++ b/client/app/scripts/components/topology-options.js @@ -1,10 +1,10 @@ import React from 'react'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import reactMixin from 'react-mixin'; +import { connect } from 'react-redux'; +import { getActiveTopologyOptions, getCurrentTopologyOptions } from '../utils/topology-utils'; import TopologyOptionAction from './topology-option-action'; -export default class TopologyOptions extends React.Component { +class TopologyOptions extends React.Component { renderOption(option) { const { activeOptions, topologyId } = this.props; @@ -26,10 +26,21 @@ export default class TopologyOptions extends React.Component { render() { return (
- {this.props.options.toIndexedSeq().map(option => this.renderOption(option))} + {this.props.options && this.props.options.toIndexedSeq().map( + option => this.renderOption(option))}
); } } -reactMixin.onClass(TopologyOptions, PureRenderMixin); +function mapStateToProps(state) { + return { + options: getCurrentTopologyOptions(state), + topologyId: state.get('currentTopologyId'), + activeOptions: getActiveTopologyOptions(state) + }; +} + +export default connect( + mapStateToProps +)(TopologyOptions); diff --git a/client/app/scripts/contrast-main.js b/client/app/scripts/contrast-main.js index 20d66e878..be647be61 100644 --- a/client/app/scripts/contrast-main.js +++ b/client/app/scripts/contrast-main.js @@ -2,9 +2,19 @@ require('font-awesome-webpack'); require('../styles/contrast.less'); require('../images/favicon.ico'); +import 'babel-polyfill'; import React from 'react'; import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import configureStore from './stores/configureStore'; import App from './components/app.js'; -ReactDOM.render(, document.getElementById('app')); +const store = configureStore(); + +ReactDOM.render( + + + , + document.getElementById('app') +); diff --git a/client/app/scripts/debug.js b/client/app/scripts/debug.js deleted file mode 100644 index a546827ee..000000000 --- a/client/app/scripts/debug.js +++ /dev/null @@ -1,3 +0,0 @@ -import Immutable from 'immutable'; -import installDevTools from 'immutable-devtools'; -installDevTools(Immutable); diff --git a/client/app/scripts/dispatcher/app-dispatcher.js b/client/app/scripts/dispatcher/app-dispatcher.js deleted file mode 100644 index 918ad5d8e..000000000 --- a/client/app/scripts/dispatcher/app-dispatcher.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Dispatcher } from 'flux'; -import _ from 'lodash'; -import debug from 'debug'; -const log = debug('scope:dispatcher'); - -const instance = new Dispatcher(); - -instance.dispatch = _.wrap(Dispatcher.prototype.dispatch, (func, payload) => { - log(payload.type, payload); - func.call(instance, payload); -}); - -export default instance; -export const dispatch = instance.dispatch.bind(instance); diff --git a/client/app/scripts/main.dev.js b/client/app/scripts/main.dev.js new file mode 100644 index 000000000..377885d63 --- /dev/null +++ b/client/app/scripts/main.dev.js @@ -0,0 +1,28 @@ +require('font-awesome-webpack'); +require('../styles/main.less'); +require('../images/favicon.ico'); + +import 'babel-polyfill'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; + +import configureStore from './stores/configureStore.dev'; +import App from './components/app'; + +import DevTools from './components/dev-tools'; +import Immutable from 'immutable'; +import installDevTools from 'immutable-devtools'; +installDevTools(Immutable); + +const store = configureStore(); + +ReactDOM.render( + +
+ + +
+
, + document.getElementById('app') +); diff --git a/client/app/scripts/main.js b/client/app/scripts/main.js index 61a28bc1a..0132b87bb 100644 --- a/client/app/scripts/main.js +++ b/client/app/scripts/main.js @@ -2,9 +2,21 @@ require('font-awesome-webpack'); require('../styles/main.less'); require('../images/favicon.ico'); +import 'babel-polyfill'; import React from 'react'; import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; -import App from './components/app.js'; +import configureStore from './stores/configureStore'; +import App from './components/app'; -ReactDOM.render(, document.getElementById('app')); +const store = configureStore(); + +ReactDOM.render( + +
+ +
+
, + document.getElementById('app') +); diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js new file mode 100644 index 000000000..506d17d8a --- /dev/null +++ b/client/app/scripts/reducers/__tests__/root-test.js @@ -0,0 +1,461 @@ +jest.dontMock('../../utils/router-utils'); +jest.dontMock('../../utils/topology-utils'); +jest.dontMock('../../constants/action-types'); +jest.dontMock('../root'); + +const is = require('immutable').is; + +// Root reducer test suite using Jasmine matchers + +describe('RootReducer', () => { + const ActionTypes = require('../../constants/action-types').default; + const reducer = require('../root').default; + const initialState = require('../root').initialState; + const topologyUtils = require('../../utils/topology-utils'); + // TODO maybe extract those to topology-utils tests? + const getActiveTopologyOptions = topologyUtils.getActiveTopologyOptions; + const getAdjacentNodes = topologyUtils.getAdjacentNodes; + const isTopologyEmpty = topologyUtils.isTopologyEmpty; + const getUrlState = require('../../utils/router-utils').getUrlState; + + // fixtures + + const NODE_SET = { + n1: { + id: 'n1', + rank: undefined, + adjacency: ['n1', 'n2'], + pseudo: undefined, + label: undefined, + label_minor: undefined + }, + n2: { + id: 'n2', + rank: undefined, + adjacency: undefined, + pseudo: undefined, + label: undefined, + label_minor: undefined + } + }; + + // actions + + const ChangeTopologyOptionAction = { + type: ActionTypes.CHANGE_TOPOLOGY_OPTION, + topologyId: 'topo1', + option: 'option1', + value: 'on' + }; + + const ChangeTopologyOptionAction2 = { + type: ActionTypes.CHANGE_TOPOLOGY_OPTION, + topologyId: 'topo1', + option: 'option1', + value: 'off' + }; + + const ClickNodeAction = { + type: ActionTypes.CLICK_NODE, + nodeId: 'n1' + }; + + const ClickNode2Action = { + type: ActionTypes.CLICK_NODE, + nodeId: 'n2' + }; + + const ClickRelativeAction = { + type: ActionTypes.CLICK_RELATIVE, + nodeId: 'rel1' + }; + + const ClickShowTopologyForNodeAction = { + type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, + topologyId: 'topo2', + nodeId: 'rel1' + }; + + const ClickSubTopologyAction = { + type: ActionTypes.CLICK_TOPOLOGY, + topologyId: 'topo1-grouped' + }; + + const ClickTopologyAction = { + type: ActionTypes.CLICK_TOPOLOGY, + topologyId: 'topo1' + }; + + const ClickTopology2Action = { + type: ActionTypes.CLICK_TOPOLOGY, + topologyId: 'topo2' + }; + + const CloseWebsocketAction = { + type: ActionTypes.CLOSE_WEBSOCKET + }; + + const deSelectNode = { + type: ActionTypes.DESELECT_NODE + }; + + const OpenWebsocketAction = { + type: ActionTypes.OPEN_WEBSOCKET + }; + + const ReceiveNodesDeltaAction = { + type: ActionTypes.RECEIVE_NODES_DELTA, + delta: { + add: [{ + id: 'n1', + adjacency: ['n1', 'n2'] + }, { + id: 'n2' + }] + } + }; + + const ReceiveNodesDeltaUpdateAction = { + type: ActionTypes.RECEIVE_NODES_DELTA, + delta: { + update: [{ + id: 'n1', + adjacency: ['n1'] + }], + remove: ['n2'] + } + }; + + const ReceiveTopologiesAction = { + type: ActionTypes.RECEIVE_TOPOLOGIES, + topologies: [{ + url: '/topo1', + name: 'Topo1', + options: [{ + id: 'option1', + defaultValue: 'off', + options: [ + {value: 'on'}, + {value: 'off'} + ] + }], + stats: { + node_count: 1 + }, + sub_topologies: [{ + url: '/topo1-grouped', + name: 'topo 1 grouped' + }] + }, { + url: '/topo2', + name: 'Topo2', + stats: { + node_count: 0 + } + }] + }; + + const RouteAction = { + type: ActionTypes.ROUTE_TOPOLOGY, + state: {} + }; + + // Basic tests + + it('returns initial state', () => { + const nextState = reducer(undefined, {}); + expect(is(nextState, initialState)).toBeTruthy(); + }); + + // topology tests + + it('init with no topologies', () => { + const nextState = reducer(undefined, {}); + expect(nextState.get('topologies').size).toBe(0); + expect(nextState.get('currentTopology')).toBeFalsy(); + }); + + it('get current topology', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + + expect(nextState.get('topologies').size).toBe(2); + expect(nextState.get('currentTopology').get('name')).toBe('Topo1'); + expect(nextState.get('currentTopology').get('url')).toBe('/topo1'); + expect(nextState.get('currentTopology').get('options').first().get('id')).toBe('option1'); + }); + + it('get sub-topology', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickSubTopologyAction); + + expect(nextState.get('topologies').size).toBe(2); + expect(nextState.get('currentTopology').get('name')).toBe('topo 1 grouped'); + expect(nextState.get('currentTopology').get('url')).toBe('/topo1-grouped'); + expect(nextState.get('currentTopology').get('options')).toBeUndefined(); + }); + + // topology options + + it('changes topology option', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + + // default options + expect(getActiveTopologyOptions(nextState).has('option1')).toBeTruthy(); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('off'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off'); + + // turn on + nextState = reducer(nextState, ChangeTopologyOptionAction); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('on'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('on'); + + // turn off + nextState = reducer(nextState, ChangeTopologyOptionAction2); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('off'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off'); + + // sub-topology should retain main topo options + nextState = reducer(nextState, ClickSubTopologyAction); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('off'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off'); + + // other topology w/o options dont return options, but keep in app state + nextState = reducer(nextState, ClickTopology2Action); + expect(getActiveTopologyOptions(nextState)).toBeUndefined(); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off'); + }); + + it('sets topology options from route', () => { + RouteAction.state = { + topologyId: 'topo1', + selectedNodeId: null, + topologyOptions: {topo1: {option1: 'on'}}}; + + let nextState = initialState; + nextState = reducer(nextState, RouteAction); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('on'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('on'); + + // stay same after topos have been received + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('on'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('on'); + }); + + it('uses default topology options from route', () => { + RouteAction.state = { + topologyId: 'topo1', + selectedNodeId: null, + topologyOptions: null}; + let nextState = initialState; + nextState = reducer(nextState, RouteAction); + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + expect(getActiveTopologyOptions(nextState).get('option1')).toBe('off'); + expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off'); + }); + + // nodes delta + + it('replaces adjacency on update', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveNodesDeltaAction); + expect(nextState.get('nodes').toJS().n1.adjacency).toEqual(['n1', 'n2']); + nextState = reducer(nextState, ReceiveNodesDeltaUpdateAction); + expect(nextState.get('nodes').toJS().n1.adjacency).toEqual(['n1']); + }); + + // browsing + + it('shows nodes that were received', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveNodesDeltaAction); + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + }); + + it('knows a route was set', () => { + let nextState = initialState; + expect(nextState.get('routeSet')).toBeFalsy(); + nextState = reducer(nextState, RouteAction); + expect(nextState.get('routeSet')).toBeTruthy(); + }); + + it('gets selected node after click', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveNodesDeltaAction); + nextState = reducer(nextState, ClickNodeAction); + + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + + nextState = reducer(nextState, deSelectNode); + expect(nextState.get('selectedNodeId')).toBe(null); + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + }); + + it('keeps showing nodes on navigating back after node click', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + nextState = reducer(nextState, ReceiveNodesDeltaAction); + expect(getUrlState(nextState).selectedNodeId).toEqual(null); + + nextState = reducer(nextState, ClickNodeAction); + expect(getUrlState(nextState).selectedNodeId).toEqual('n1'); + + // go back in browsing + RouteAction.state = {topologyId: 'topo1', selectedNodeId: null}; + nextState = reducer(nextState, RouteAction); + expect(nextState.get('selectedNodeId')).toBe(null); + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + }); + + it('closes details when changing topologies', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + nextState = reducer(nextState, ReceiveNodesDeltaAction); + + expect(getUrlState(nextState).selectedNodeId).toEqual(null); + expect(getUrlState(nextState).topologyId).toEqual('topo1'); + + nextState = reducer(nextState, ClickNodeAction); + expect(getUrlState(nextState).selectedNodeId).toEqual('n1'); + expect(getUrlState(nextState).topologyId).toEqual('topo1'); + + nextState = reducer(nextState, ClickSubTopologyAction); + expect(getUrlState(nextState).selectedNodeId).toEqual(null); + expect(getUrlState(nextState).topologyId).toEqual('topo1-grouped'); + }); + + // connection errors + + it('resets topology on websocket reconnect', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveNodesDeltaAction); + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + + nextState = reducer(nextState, CloseWebsocketAction); + expect(nextState.get('websocketClosed')).toBeTruthy(); + // keep showing old nodes + expect(nextState.get('nodes').toJS()).toEqual(NODE_SET); + + nextState = reducer(nextState, OpenWebsocketAction); + expect(nextState.get('websocketClosed')).toBeFalsy(); + // opened socket clears nodes + expect(nextState.get('nodes').toJS()).toEqual({}); + }); + + // adjacency test + + it('returns the correct adjacency set for a node', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveNodesDeltaAction); + expect(getAdjacentNodes(nextState).size).toEqual(0); + + nextState = reducer(nextState, ClickNodeAction); + expect(getAdjacentNodes(nextState, 'n1').size).toEqual(2); + expect(getAdjacentNodes(nextState, 'n1').has('n1')).toBeTruthy(); + expect(getAdjacentNodes(nextState, 'n1').has('n2')).toBeTruthy(); + + nextState = reducer(nextState, deSelectNode); + expect(getAdjacentNodes(nextState).size).toEqual(0); + }); + + // empty topology + + it('detects that the topology is empty', () => { + let nextState = initialState; + nextState = reducer(nextState, ReceiveTopologiesAction); + nextState = reducer(nextState, ClickTopologyAction); + expect(isTopologyEmpty(nextState)).toBeFalsy(); + + nextState = reducer(nextState, ClickTopology2Action); + expect(isTopologyEmpty(nextState)).toBeTruthy(); + + nextState = reducer(nextState, ClickTopologyAction); + expect(isTopologyEmpty(nextState)).toBeFalsy(); + }); + + // selection of relatives + + it('keeps relatives as a stack', () => { + let nextState = initialState; + nextState = reducer(nextState, ClickNodeAction); + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('nodeDetails').has('n1')).toBeTruthy(); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('n1'); + + nextState = reducer(nextState, ClickRelativeAction); + // stack relative, first node stays main node + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('rel1'); + expect(nextState.get('nodeDetails').size).toEqual(2); + expect(nextState.get('nodeDetails').has('rel1')).toBeTruthy(); + + // click on first node should clear the stack + nextState = reducer(nextState, ClickNodeAction); + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('n1'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('nodeDetails').has('rel1')).toBeFalsy(); + }); + + it('keeps clears stack when sibling is clicked', () => { + let nextState = initialState; + nextState = reducer(nextState, ClickNodeAction); + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('nodeDetails').has('n1')).toBeTruthy(); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('n1'); + + nextState = reducer(nextState, ClickRelativeAction); + // stack relative, first node stays main node + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('rel1'); + expect(nextState.get('nodeDetails').size).toEqual(2); + expect(nextState.get('nodeDetails').has('rel1')).toBeTruthy(); + + // click on sibling node should clear the stack + nextState = reducer(nextState, ClickNode2Action); + expect(nextState.get('selectedNodeId')).toBe('n2'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('n2'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('nodeDetails').has('n1')).toBeFalsy(); + expect(nextState.get('nodeDetails').has('rel1')).toBeFalsy(); + }); + + it('selectes relatives topology while keeping node selected', () => { + let nextState = initialState; + nextState = reducer(nextState, ClickTopologyAction); + nextState = reducer(nextState, ReceiveTopologiesAction); + expect(nextState.get('currentTopology').get('name')).toBe('Topo1'); + + nextState = reducer(nextState, ClickNodeAction); + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('nodeDetails').has('n1')).toBeTruthy(); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('n1'); + + nextState = reducer(nextState, ClickRelativeAction); + // stack relative, first node stays main node + expect(nextState.get('selectedNodeId')).toBe('n1'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('rel1'); + expect(nextState.get('nodeDetails').size).toEqual(2); + expect(nextState.get('nodeDetails').has('rel1')).toBeTruthy(); + + // click switches over to relative's topology and selectes relative + nextState = reducer(nextState, ClickShowTopologyForNodeAction); + expect(nextState.get('selectedNodeId')).toBe('rel1'); + expect(nextState.get('nodeDetails').keySeq().last()).toEqual('rel1'); + expect(nextState.get('nodeDetails').size).toEqual(1); + expect(nextState.get('currentTopology').get('name')).toBe('Topo2'); + }); +}); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js new file mode 100644 index 000000000..e9711554b --- /dev/null +++ b/client/app/scripts/reducers/root.js @@ -0,0 +1,560 @@ +import _ from 'lodash'; +import debug from 'debug'; +import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap, + OrderedMap as makeOrderedMap, Set as makeSet } from 'immutable'; + +import ActionTypes from '../constants/action-types'; +import { EDGE_ID_SEPARATOR } from '../constants/naming'; +import { findTopologyById, getAdjacentNodes, setTopologyUrlsById, + updateTopologyIds, filterHiddenTopologies } from '../utils/topology-utils'; + +const log = debug('scope:app-store'); +const error = debug('scope:error'); + +// Helpers + +function makeNode(node) { + return { + id: node.id, + label: node.label, + label_minor: node.label_minor, + node_count: node.node_count, + rank: node.rank, + pseudo: node.pseudo, + stack: node.stack, + shape: node.shape, + adjacency: node.adjacency, + metrics: node.metrics + }; +} + +const topologySorter = topology => topology.get('rank'); + +// Initial values + +export const initialState = makeMap({ + topologyOptions: makeOrderedMap(), // topologyId -> options + controlStatus: makeMap(), + currentTopology: null, + currentTopologyId: 'containers', + errorUrl: null, + forceRelayout: false, + highlightedEdgeIds: makeSet(), + highlightedNodeIds: makeSet(), + hostname: '...', + version: '...', + versionUpdate: null, + plugins: makeList(), + mouseOverEdgeId: null, + mouseOverNodeId: null, + nodeDetails: makeOrderedMap(), // nodeId -> details + nodes: makeOrderedMap(), // nodeId -> node + selectedNodeId: null, + topologies: makeList(), + topologiesLoaded: false, + topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl + routeSet: false, + controlPipes: makeOrderedMap(), // pipeId -> controlPipe + updatePausedAt: null, // Date + websocketClosed: true, + showingHelp: false, + + selectedMetric: null, + pinnedMetric: null, + // class of metric, e.g. 'cpu', rather than 'host_cpu' or 'process_cpu'. + // allows us to keep the same metric "type" selected when the topology changes. + pinnedMetricType: null, + availableCanvasMetrics: makeList() +}); + +// adds ID field to topology (based on last part of URL path) and save urls in +// map for easy lookup +function processTopologies(state, nextTopologies) { + // filter out hidden topos + const visibleTopologies = filterHiddenTopologies(nextTopologies); + + // add IDs to topology objects in-place + const topologiesWithId = updateTopologyIds(visibleTopologies); + + // cache URLs by ID + state = state.set('topologyUrlsById', + setTopologyUrlsById(state.get('topologyUrlsById'), topologiesWithId)); + + const immNextTopologies = fromJS(topologiesWithId).sortBy(topologySorter); + return state.mergeDeepIn(['topologies'], immNextTopologies); +} + +function setTopology(state, topologyId) { + state = state.set('currentTopology', findTopologyById( + state.get('topologies'), topologyId)); + return state.set('currentTopologyId', topologyId); +} + +function setDefaultTopologyOptions(state, topologyList) { + topologyList.forEach(topology => { + let defaultOptions = makeOrderedMap(); + if (topology.has('options') && topology.get('options')) { + topology.get('options').forEach((option) => { + const optionId = option.get('id'); + const defaultValue = option.get('defaultValue'); + defaultOptions = defaultOptions.set(optionId, defaultValue); + }); + } + + if (defaultOptions.size) { + state = state.setIn(['topologyOptions', topology.get('id')], + defaultOptions + ); + } + }); + return state; +} + +function closeNodeDetails(state, nodeId) { + const nodeDetails = state.get('nodeDetails'); + if (nodeDetails.size > 0) { + const popNodeId = nodeId || nodeDetails.keySeq().last(); + // remove pipe if it belongs to the node being closed + state = state.update('controlPipes', + controlPipes => controlPipes.filter(pipe => pipe.get('nodeId') !== popNodeId)); + state = state.deleteIn(['nodeDetails', popNodeId]); + } + if (state.get('nodeDetails').size === 0 || state.get('selectedNodeId') === nodeId) { + state = state.set('selectedNodeId', null); + } + return state; +} + +function closeAllNodeDetails(state) { + while (state.get('nodeDetails').size) { + state = closeNodeDetails(state); + } + return state; +} + +function resumeUpdate(state) { + return state.set('updatePausedAt', null); +} + +export function rootReducer(state = initialState, action) { + if (!action.type) { + error('Payload missing a type!', action); + } + + switch (action.type) { + case ActionTypes.CHANGE_TOPOLOGY_OPTION: { + state = resumeUpdate(state); + // set option on parent topology + const topology = findTopologyById(state.get('topologies'), action.topologyId); + if (topology) { + const topologyId = topology.get('parentId') || topology.get('id'); + if (state.getIn(['topologyOptions', topologyId, action.option]) !== action.value) { + state = state.update('nodes', nodes => nodes.clear()); + } + state = state.setIn( + ['topologyOptions', topologyId, action.option], + action.value + ); + } + return state; + } + + case ActionTypes.CLEAR_CONTROL_ERROR: { + return state.removeIn(['controlStatus', action.nodeId, 'error']); + } + + case ActionTypes.CLICK_BACKGROUND: { + return closeAllNodeDetails(state); + } + + case ActionTypes.CLICK_CLOSE_DETAILS: { + return closeNodeDetails(state, action.nodeId); + } + + case ActionTypes.CLICK_CLOSE_TERMINAL: { + return state.update('controlPipes', controlPipes => controlPipes.clear()); + } + + case ActionTypes.CLICK_FORCE_RELAYOUT: { + return state.set('forceRelayout', action.forceRelayout); + } + + case ActionTypes.CLICK_NODE: { + const prevSelectedNodeId = state.get('selectedNodeId'); + const prevDetailsStackSize = state.get('nodeDetails').size; + + // click on sibling closes all + state = closeAllNodeDetails(state); + + // select new node if it's not the same (in that case just delesect) + if (prevDetailsStackSize > 1 || prevSelectedNodeId !== action.nodeId) { + // dont set origin if a node was already selected, suppresses animation + const origin = prevSelectedNodeId === null ? action.origin : null; + state = state.setIn(['nodeDetails', action.nodeId], + { + id: action.nodeId, + label: action.label, + origin, + topologyId: state.get('currentTopologyId') + } + ); + state = state.set('selectedNodeId', action.nodeId); + } + return state; + } + + case ActionTypes.CLICK_PAUSE_UPDATE: { + return state.set('updatePausedAt', new Date); + } + + case ActionTypes.CLICK_RELATIVE: { + if (state.hasIn(['nodeDetails', action.nodeId])) { + // bring to front + const details = state.getIn(['nodeDetails', action.nodeId]); + state = state.deleteIn(['nodeDetails', action.nodeId]); + state = state.setIn(['nodeDetails', action.nodeId], details); + } else { + state = state.setIn(['nodeDetails', action.nodeId], + { + id: action.nodeId, + label: action.label, + origin: action.origin, + topologyId: action.topologyId + } + ); + } + return state; + } + + case ActionTypes.CLICK_RESUME_UPDATE: { + return resumeUpdate(state); + } + + case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: { + state = resumeUpdate(state); + + state = state.update('nodeDetails', + nodeDetails => nodeDetails.filter((v, k) => k === action.nodeId)); + state = state.update('controlPipes', controlPipes => controlPipes.clear()); + state = state.set('selectedNodeId', action.nodeId); + + if (action.topologyId !== state.get('currentTopologyId')) { + state = setTopology(state, action.topologyId); + state = state.update('nodes', nodes => nodes.clear()); + } + state = state.set('availableCanvasMetrics', makeList()); + return state; + } + + case ActionTypes.CLICK_TOPOLOGY: { + state = resumeUpdate(state); + state = closeAllNodeDetails(state); + + if (action.topologyId !== state.get('currentTopologyId')) { + state = setTopology(state, action.topologyId); + state = state.update('nodes', nodes => nodes.clear()); + } + state = state.set('availableCanvasMetrics', makeList()); + return state; + } + + case ActionTypes.CLOSE_WEBSOCKET: { + if (!state.get('websocketClosed')) { + state = state.set('websocketClosed', true); + } + return state; + } + + case ActionTypes.SELECT_METRIC: { + return state.set('selectedMetric', action.metricId); + } + + case ActionTypes.PIN_METRIC: { + const metricTypes = makeMap( + state.get('availableCanvasMetrics').map(m => [m.get('id'), m.get('label')])); + return state.merge({ + pinnedMetric: action.metricId, + pinnedMetricType: metricTypes.get(action.metricId), + selectedMetric: action.metricId + }); + } + + case ActionTypes.UNPIN_METRIC: { + return state.merge({ + pinnedMetric: null, + pinnedMetricType: null + }); + } + + case ActionTypes.SHOW_HELP: { + return state.set('showingHelp', true); + } + + case ActionTypes.HIDE_HELP: { + return state.set('showingHelp', false); + } + + case ActionTypes.DESELECT_NODE: { + return closeNodeDetails(state); + } + + case ActionTypes.DO_CONTROL: { + return state.setIn(['controlStatus', action.nodeId], makeMap({ + pending: true, + error: null + })); + } + + case ActionTypes.ENTER_EDGE: { + // highlight adjacent nodes + state = state.update('highlightedNodeIds', highlightedNodeIds => { + highlightedNodeIds = highlightedNodeIds.clear(); + return highlightedNodeIds.union(action.edgeId.split(EDGE_ID_SEPARATOR)); + }); + + // highlight edge + state = state.update('highlightedEdgeIds', highlightedEdgeIds => { + highlightedEdgeIds = highlightedEdgeIds.clear(); + return highlightedEdgeIds.add(action.edgeId); + }); + + return state; + } + + case ActionTypes.ENTER_NODE: { + const nodeId = action.nodeId; + const adjacentNodes = getAdjacentNodes(state, nodeId); + + // highlight adjacent nodes + state = state.update('highlightedNodeIds', highlightedNodeIds => { + highlightedNodeIds = highlightedNodeIds.clear(); + highlightedNodeIds = highlightedNodeIds.add(nodeId); + return highlightedNodeIds.union(adjacentNodes); + }); + + // highlight edge + state = state.update('highlightedEdgeIds', highlightedEdgeIds => { + highlightedEdgeIds = highlightedEdgeIds.clear(); + if (adjacentNodes.size > 0) { + // all neighbour combinations because we dont know which direction exists + highlightedEdgeIds = highlightedEdgeIds.union(adjacentNodes.flatMap((adjacentId) => [ + [adjacentId, nodeId].join(EDGE_ID_SEPARATOR), + [nodeId, adjacentId].join(EDGE_ID_SEPARATOR) + ])); + } + return highlightedEdgeIds; + }); + + return state; + } + + case ActionTypes.LEAVE_EDGE: { + state = state.update('highlightedEdgeIds', highlightedEdgeIds => highlightedEdgeIds.clear()); + state = state.update('highlightedNodeIds', highlightedNodeIds => highlightedNodeIds.clear()); + return state; + } + + case ActionTypes.LEAVE_NODE: { + state = state.update('highlightedEdgeIds', highlightedEdgeIds => highlightedEdgeIds.clear()); + state = state.update('highlightedNodeIds', highlightedNodeIds => highlightedNodeIds.clear()); + return state; + } + + case ActionTypes.OPEN_WEBSOCKET: { + // flush nodes cache after re-connect + state = state.update('nodes', nodes => nodes.clear()); + state = state.set('websocketClosed', false); + return state; + } + + case ActionTypes.DO_CONTROL_ERROR: { + return state.setIn(['controlStatus', action.nodeId], makeMap({ + pending: false, + error: action.error + })); + } + case ActionTypes.DO_CONTROL_SUCCESS: { + return state.setIn(['controlStatus', action.nodeId], makeMap({ + pending: false, + error: null + })); + } + + case ActionTypes.RECEIVE_CONTROL_NODE_REMOVED: { + return closeNodeDetails(state, action.nodeId); + } + + case ActionTypes.RECEIVE_CONTROL_PIPE: { + return state.setIn(['controlPipes', action.pipeId], makeOrderedMap({ + id: action.pipeId, + nodeId: action.nodeId, + raw: action.rawTty + })); + } + + case ActionTypes.RECEIVE_CONTROL_PIPE_STATUS: { + if (state.hasIn(['controlPipes', action.pipeId])) { + state = state.setIn(['controlPipes', action.pipeId, 'status'], action.status); + } + return state; + } + + case ActionTypes.RECEIVE_ERROR: { + if (state.get('errorUrl') !== null) { + state = state.set('errorUrl', action.errorUrl); + } + return state; + } + + case ActionTypes.RECEIVE_NODE_DETAILS: { + state = state.set('errorUrl', null); + + // disregard if node is not selected anymore + if (state.hasIn(['nodeDetails', action.details.id])) { + state = state.updateIn(['nodeDetails', action.details.id], obj => { + const result = Object.assign({}, obj); + result.notFound = false; + result.details = action.details; + return result; + }); + } + return state; + } + + case ActionTypes.RECEIVE_NODES_DELTA: { + const emptyMessage = !action.delta.add && !action.delta.remove + && !action.delta.update; + + if (!emptyMessage) { + log('RECEIVE_NODES_DELTA', + 'remove', _.size(action.delta.remove), + 'update', _.size(action.delta.update), + 'add', _.size(action.delta.add)); + } + + state = state.set('errorUrl', null); + + // nodes that no longer exist + _.each(action.delta.remove, (nodeId) => { + // in case node disappears before mouseleave event + if (state.get('mouseOverNodeId') === nodeId) { + state = state.set('mouseOverNodeId', null); + } + if (state.hasIn(['nodes', nodeId]) && _.includes(state.get('mouseOverEdgeId'), nodeId)) { + state = state.set('mouseOverEdgeId', null); + } + state = state.deleteIn(['nodes', nodeId]); + }); + + // update existing nodes + _.each(action.delta.update, (node) => { + if (state.hasIn(['nodes', node.id])) { + state = state.updateIn(['nodes', node.id], n => n.merge(fromJS(node))); + } + }); + + // add new nodes + _.each(action.delta.add, (node) => { + state = state.setIn(['nodes', node.id], fromJS(makeNode(node))); + }); + + state = state.set('availableCanvasMetrics', state.get('nodes') + .valueSeq() + .flatMap(n => (n.get('metrics') || makeList()).map(m => ( + makeMap({id: m.get('id'), label: m.get('label')}) + ))) + .toSet() + .toList() + .sortBy(m => m.get('label'))); + + const similarTypeMetric = state.get('availableCanvasMetrics') + .find(m => m.get('label') === state.get('pinnedMetricType')); + state = state.set('pinnedMetric', similarTypeMetric && similarTypeMetric.get('id')); + // if something in the current topo is not already selected, select it. + if (!state.get('availableCanvasMetrics') + .map(m => m.get('id')) + .toSet() + .has(state.get('selectedMetric'))) { + state = state.set('selectedMetric', state.get('pinnedMetric')); + } + + return state; + } + + case ActionTypes.RECEIVE_NOT_FOUND: { + if (state.hasIn(['nodeDetails', action.nodeId])) { + state = state.updateIn(['nodeDetails', action.nodeId], obj => { + const result = Object.assign({}, obj); + result.notFound = true; + return result; + }); + } + return state; + } + + case ActionTypes.RECEIVE_TOPOLOGIES: { + state = state.set('errorUrl', null); + state = state.update('topologyUrlsById', topologyUrlsById => topologyUrlsById.clear()); + state = processTopologies(state, action.topologies); + state = setTopology(state, state.get('currentTopologyId')); + // only set on first load, if options are not already set via route + if (!state.get('topologiesLoaded') && state.get('topologyOptions').size === 0) { + state = setDefaultTopologyOptions(state, state.get('topologies')); + } + state = state.set('topologiesLoaded', true); + + return state; + } + + case ActionTypes.RECEIVE_API_DETAILS: { + state = state.set('errorUrl', null); + + return state.merge({ + hostname: action.hostname, + version: action.version, + plugins: action.plugins, + versionUpdate: action.newVersion + }); + } + + case ActionTypes.ROUTE_TOPOLOGY: { + state = state.set('routeSet', true); + if (state.get('currentTopologyId') !== action.state.topologyId) { + state = state.update('nodes', nodes => nodes.clear()); + } + state = setTopology(state, action.state.topologyId); + state = setDefaultTopologyOptions(state, state.get('topologies')); + state = state.merge({ + selectedNodeId: action.state.selectedNodeId, + pinnedMetricType: action.state.pinnedMetricType + }); + if (action.state.controlPipe) { + state = state.set('controlPipes', makeOrderedMap({ + [action.state.controlPipe.id]: + makeOrderedMap(action.state.controlPipe) + })); + } else { + state = state.update('controlPipes', controlPipes => controlPipes.clear()); + } + if (action.state.nodeDetails) { + const actionNodeDetails = makeOrderedMap( + action.state.nodeDetails.map(obj => [obj.id, obj])); + // check if detail IDs have changed + if (!isDeepEqual(state.get('nodeDetails').keySeq(), actionNodeDetails.keySeq())) { + state = state.set('nodeDetails', actionNodeDetails); + } + } else { + state = state.update('nodeDetails', nodeDetails => nodeDetails.clear()); + } + state = state.set('topologyOptions', + fromJS(action.state.topologyOptions) || state.get('topologyOptions')); + return state; + } + + default: { + return state; + } + } +} + +export default rootReducer; diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js deleted file mode 100644 index 80b54059e..000000000 --- a/client/app/scripts/stores/__tests__/app-store-test.js +++ /dev/null @@ -1,434 +0,0 @@ -jest.dontMock('../../utils/topology-utils'); -jest.dontMock('../../constants/action-types'); -jest.dontMock('../app-store'); - -// Appstore test suite using Jasmine matchers - -describe('AppStore', () => { - const ActionTypes = require('../../constants/action-types').default; - let AppStore; - let registeredCallback; - - // fixtures - - const NODE_SET = { - n1: { - id: 'n1', - rank: undefined, - adjacency: ['n1', 'n2'], - pseudo: undefined, - label: undefined, - label_minor: undefined - }, - n2: { - id: 'n2', - rank: undefined, - adjacency: undefined, - pseudo: undefined, - label: undefined, - label_minor: undefined - } - }; - - // actions - - const ChangeTopologyOptionAction = { - type: ActionTypes.CHANGE_TOPOLOGY_OPTION, - topologyId: 'topo1', - option: 'option1', - value: 'on' - }; - - const ChangeTopologyOptionAction2 = { - type: ActionTypes.CHANGE_TOPOLOGY_OPTION, - topologyId: 'topo1', - option: 'option1', - value: 'off' - }; - - const ClickNodeAction = { - type: ActionTypes.CLICK_NODE, - nodeId: 'n1' - }; - - const ClickNode2Action = { - type: ActionTypes.CLICK_NODE, - nodeId: 'n2' - }; - - const ClickRelativeAction = { - type: ActionTypes.CLICK_RELATIVE, - nodeId: 'rel1' - }; - - const ClickShowTopologyForNodeAction = { - type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, - topologyId: 'topo2', - nodeId: 'rel1' - }; - - const ClickSubTopologyAction = { - type: ActionTypes.CLICK_TOPOLOGY, - topologyId: 'topo1-grouped' - }; - - const ClickTopologyAction = { - type: ActionTypes.CLICK_TOPOLOGY, - topologyId: 'topo1' - }; - - const ClickTopology2Action = { - type: ActionTypes.CLICK_TOPOLOGY, - topologyId: 'topo2' - }; - - const CloseWebsocketAction = { - type: ActionTypes.CLOSE_WEBSOCKET - }; - - const deSelectNode = { - type: ActionTypes.DESELECT_NODE - }; - - const OpenWebsocketAction = { - type: ActionTypes.OPEN_WEBSOCKET - }; - - const ReceiveNodesDeltaAction = { - type: ActionTypes.RECEIVE_NODES_DELTA, - delta: { - add: [{ - id: 'n1', - adjacency: ['n1', 'n2'] - }, { - id: 'n2' - }] - } - }; - - const ReceiveNodesDeltaUpdateAction = { - type: ActionTypes.RECEIVE_NODES_DELTA, - delta: { - update: [{ - id: 'n1', - adjacency: ['n1'] - }], - remove: ['n2'] - } - }; - - const ReceiveTopologiesAction = { - type: ActionTypes.RECEIVE_TOPOLOGIES, - topologies: [{ - url: '/topo1', - name: 'Topo1', - options: [{ - id: 'option1', - defaultValue: 'off', - options: [ - {value: 'on'}, - {value: 'off'} - ] - }], - stats: { - node_count: 1 - }, - sub_topologies: [{ - url: '/topo1-grouped', - name: 'topo 1 grouped' - }] - }, { - url: '/topo2', - name: 'Topo2', - stats: { - node_count: 0 - } - }] - }; - - const RouteAction = { - type: ActionTypes.ROUTE_TOPOLOGY, - state: {} - }; - - beforeEach(() => { - AppStore = require('../app-store').default; - const AppDispatcher = AppStore.getDispatcher(); - const callback = AppDispatcher.dispatch.bind(AppDispatcher); - registeredCallback = callback; - }); - - // topology tests - - it('init with no topologies', () => { - const topos = AppStore.getTopologies(); - expect(topos.size).toBe(0); - expect(AppStore.getCurrentTopology()).toBeUndefined(); - }); - - it('get current topology', () => { - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - - expect(AppStore.getTopologies().size).toBe(2); - expect(AppStore.getCurrentTopology().get('name')).toBe('Topo1'); - expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1'); - expect(AppStore.getCurrentTopologyOptions().first().get('id')).toBe('option1'); - }); - - it('get sub-topology', () => { - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickSubTopologyAction); - - expect(AppStore.getTopologies().size).toBe(2); - expect(AppStore.getCurrentTopology().get('name')).toBe('topo 1 grouped'); - expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1-grouped'); - expect(AppStore.getCurrentTopologyOptions().size).toBe(0); - }); - - // topology options - - it('changes topology option', () => { - // default options - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - expect(AppStore.getActiveTopologyOptions().has('option1')).toBeTruthy(); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('off'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('off'); - - // turn on - registeredCallback(ChangeTopologyOptionAction); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('on'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('on'); - - // turn off - registeredCallback(ChangeTopologyOptionAction2); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('off'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('off'); - - // sub-topology should retain main topo options - registeredCallback(ClickSubTopologyAction); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('off'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('off'); - - // other topology w/o options dont return options, but keep in app state - registeredCallback(ClickTopology2Action); - expect(AppStore.getActiveTopologyOptions()).toBeUndefined(); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('off'); - }); - - it('sets topology options from route', () => { - RouteAction.state = { - topologyId: 'topo1', - selectedNodeId: null, - topologyOptions: {topo1: {option1: 'on'}}}; - registeredCallback(RouteAction); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('on'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('on'); - - // stay same after topos have been received - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('on'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('on'); - }); - - it('uses default topology options from route', () => { - RouteAction.state = { - topologyId: 'topo1', - selectedNodeId: null, - topologyOptions: null}; - registeredCallback(RouteAction); - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - expect(AppStore.getActiveTopologyOptions().get('option1')).toBe('off'); - expect(AppStore.getAppState().topologyOptions.topo1.option1).toBe('off'); - }); - - // nodes delta - - it('replaces adjacency on update', () => { - registeredCallback(ReceiveNodesDeltaAction); - expect(AppStore.getNodes().toJS().n1.adjacency).toEqual(['n1', 'n2']); - registeredCallback(ReceiveNodesDeltaUpdateAction); - expect(AppStore.getNodes().toJS().n1.adjacency).toEqual(['n1']); - }); - - // browsing - - it('shows nodes that were received', () => { - registeredCallback(ReceiveNodesDeltaAction); - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - }); - - it('knows a route was set', () => { - expect(AppStore.isRouteSet()).toBeFalsy(); - registeredCallback(RouteAction); - expect(AppStore.isRouteSet()).toBeTruthy(); - }); - - it('gets selected node after click', () => { - registeredCallback(ReceiveNodesDeltaAction); - - registeredCallback(ClickNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - - registeredCallback(deSelectNode); - expect(AppStore.getSelectedNodeId()).toBe(null); - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - }); - - it('keeps showing nodes on navigating back after node click', () => { - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - registeredCallback(ReceiveNodesDeltaAction); - - expect(AppStore.getAppState().selectedNodeId).toEqual(null); - - registeredCallback(ClickNodeAction); - expect(AppStore.getAppState().selectedNodeId).toEqual('n1'); - - // go back in browsing - RouteAction.state = {topologyId: 'topo1', selectedNodeId: null}; - registeredCallback(RouteAction); - expect(AppStore.getSelectedNodeId()).toBe(null); - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - }); - - it('closes details when changing topologies', () => { - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - registeredCallback(ReceiveNodesDeltaAction); - - expect(AppStore.getAppState().selectedNodeId).toEqual(null); - expect(AppStore.getAppState().topologyId).toEqual('topo1'); - - registeredCallback(ClickNodeAction); - expect(AppStore.getAppState().selectedNodeId).toEqual('n1'); - expect(AppStore.getAppState().topologyId).toEqual('topo1'); - - registeredCallback(ClickSubTopologyAction); - expect(AppStore.getAppState().selectedNodeId).toEqual(null); - expect(AppStore.getAppState().topologyId).toEqual('topo1-grouped'); - }); - - // connection errors - - it('resets topology on websocket reconnect', () => { - registeredCallback(ReceiveNodesDeltaAction); - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - - registeredCallback(CloseWebsocketAction); - expect(AppStore.isWebsocketClosed()).toBeTruthy(); - // keep showing old nodes - expect(AppStore.getNodes().toJS()).toEqual(NODE_SET); - - registeredCallback(OpenWebsocketAction); - expect(AppStore.isWebsocketClosed()).toBeFalsy(); - // opened socket clears nodes - expect(AppStore.getNodes().toJS()).toEqual({}); - }); - - // adjacency test - - it('returns the correct adjacency set for a node', () => { - registeredCallback(ReceiveNodesDeltaAction); - expect(AppStore.getAdjacentNodes().size).toEqual(0); - - registeredCallback(ClickNodeAction); - expect(AppStore.getAdjacentNodes('n1').size).toEqual(2); - expect(AppStore.getAdjacentNodes('n1').has('n1')).toBeTruthy(); - expect(AppStore.getAdjacentNodes('n1').has('n2')).toBeTruthy(); - - registeredCallback(deSelectNode); - expect(AppStore.getAdjacentNodes().size).toEqual(0); - }); - - // empty topology - - it('detects that the topology is empty', () => { - registeredCallback(ReceiveTopologiesAction); - registeredCallback(ClickTopologyAction); - expect(AppStore.isTopologyEmpty()).toBeFalsy(); - - registeredCallback(ClickTopology2Action); - expect(AppStore.isTopologyEmpty()).toBeTruthy(); - - registeredCallback(ClickTopologyAction); - expect(AppStore.isTopologyEmpty()).toBeFalsy(); - }); - - // selection of relatives - - it('keeps relatives as a stack', () => { - registeredCallback(ClickNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getNodeDetails().has('n1')).toBeTruthy(); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1'); - - registeredCallback(ClickRelativeAction); - // stack relative, first node stays main node - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1'); - expect(AppStore.getNodeDetails().size).toEqual(2); - expect(AppStore.getNodeDetails().has('rel1')).toBeTruthy(); - - // click on first node should clear the stack - registeredCallback(ClickNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getNodeDetails().has('rel1')).toBeFalsy(); - }); - - it('keeps clears stack when sibling is clicked', () => { - registeredCallback(ClickNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getNodeDetails().has('n1')).toBeTruthy(); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1'); - - registeredCallback(ClickRelativeAction); - // stack relative, first node stays main node - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1'); - expect(AppStore.getNodeDetails().size).toEqual(2); - expect(AppStore.getNodeDetails().has('rel1')).toBeTruthy(); - - // click on sibling node should clear the stack - registeredCallback(ClickNode2Action); - expect(AppStore.getSelectedNodeId()).toBe('n2'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n2'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getNodeDetails().has('n1')).toBeFalsy(); - expect(AppStore.getNodeDetails().has('rel1')).toBeFalsy(); - }); - - it('selectes relatives topology while keeping node selected', () => { - registeredCallback(ClickTopologyAction); - registeredCallback(ReceiveTopologiesAction); - expect(AppStore.getCurrentTopology().get('name')).toBe('Topo1'); - - registeredCallback(ClickNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getNodeDetails().has('n1')).toBeTruthy(); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1'); - - registeredCallback(ClickRelativeAction); - // stack relative, first node stays main node - expect(AppStore.getSelectedNodeId()).toBe('n1'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1'); - expect(AppStore.getNodeDetails().size).toEqual(2); - expect(AppStore.getNodeDetails().has('rel1')).toBeTruthy(); - - // click switches over to relative's topology and selectes relative - registeredCallback(ClickShowTopologyForNodeAction); - expect(AppStore.getSelectedNodeId()).toBe('rel1'); - expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1'); - expect(AppStore.getNodeDetails().size).toEqual(1); - expect(AppStore.getCurrentTopology().get('name')).toBe('Topo2'); - }); -}); diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js deleted file mode 100644 index cce23cb64..000000000 --- a/client/app/scripts/stores/app-store.js +++ /dev/null @@ -1,741 +0,0 @@ -import _ from 'lodash'; -import debug from 'debug'; -import { fromJS, is as isDeepEqual, List, Map, OrderedMap, Set } from 'immutable'; -import { Store } from 'flux/utils'; - -import AppDispatcher from '../dispatcher/app-dispatcher'; -import ActionTypes from '../constants/action-types'; -import { EDGE_ID_SEPARATOR } from '../constants/naming'; -import { findTopologyById, setTopologyUrlsById, updateTopologyIds, - filterHiddenTopologies } from '../utils/topology-utils'; - -const makeList = List; -const makeMap = Map; -const makeOrderedMap = OrderedMap; -const makeSet = Set; -const log = debug('scope:app-store'); - -const error = debug('scope:error'); - -// Helpers - -function makeNode(node) { - return { - id: node.id, - label: node.label, - label_minor: node.label_minor, - node_count: node.node_count, - rank: node.rank, - pseudo: node.pseudo, - stack: node.stack, - shape: node.shape, - adjacency: node.adjacency, - metrics: node.metrics - }; -} - -// Initial values - -let topologyOptions = makeOrderedMap(); // topologyId -> options -let controlStatus = makeMap(); -let currentTopology = null; -let currentTopologyId = 'containers'; -let errorUrl = null; -let forceRelayout = false; -let highlightedEdgeIds = makeSet(); -let highlightedNodeIds = makeSet(); -let hostname = '...'; -let version = '...'; -let versionUpdate = null; -let plugins = []; -let mouseOverEdgeId = null; -let mouseOverNodeId = null; -let nodeDetails = makeOrderedMap(); // nodeId -> details -let nodes = makeOrderedMap(); // nodeId -> node -let selectedNodeId = null; -let topologies = makeList(); -let topologiesLoaded = false; -let topologyUrlsById = makeOrderedMap(); // topologyId -> topologyUrl -let routeSet = false; -let controlPipes = makeOrderedMap(); // pipeId -> controlPipe -let updatePausedAt = null; // Date -let websocketClosed = true; -let showingHelp = false; - -let selectedMetric = null; -let pinnedMetric = selectedMetric; -// class of metric, e.g. 'cpu', rather than 'host_cpu' or 'process_cpu'. -// allows us to keep the same metric "type" selected when the topology changes. -let pinnedMetricType = null; -let availableCanvasMetrics = makeList(); - - -const topologySorter = topology => topology.get('rank'); - -// adds ID field to topology (based on last part of URL path) and save urls in -// map for easy lookup -function processTopologies(nextTopologies) { - // filter out hidden topos - const visibleTopologies = filterHiddenTopologies(nextTopologies); - - // add IDs to topology objects in-place - const topologiesWithId = updateTopologyIds(visibleTopologies); - - // cache URLs by ID - topologyUrlsById = setTopologyUrlsById(topologyUrlsById, topologiesWithId); - - const immNextTopologies = fromJS(topologiesWithId).sortBy(topologySorter); - topologies = topologies.mergeDeep(immNextTopologies); -} - -function setTopology(topologyId) { - currentTopology = findTopologyById(topologies, topologyId); - currentTopologyId = topologyId; -} - -function setDefaultTopologyOptions(topologyList) { - topologyList.forEach(topology => { - let defaultOptions = makeOrderedMap(); - if (topology.has('options') && topology.get('options')) { - topology.get('options').forEach((option) => { - const optionId = option.get('id'); - const defaultValue = option.get('defaultValue'); - defaultOptions = defaultOptions.set(optionId, defaultValue); - }); - } - - if (defaultOptions.size) { - topologyOptions = topologyOptions.set( - topology.get('id'), - defaultOptions - ); - } - }); -} - -function closeNodeDetails(nodeId) { - if (nodeDetails.size > 0) { - const popNodeId = nodeId || nodeDetails.keySeq().last(); - // remove pipe if it belongs to the node being closed - controlPipes = controlPipes.filter(pipe => pipe.get('nodeId') !== popNodeId); - nodeDetails = nodeDetails.delete(popNodeId); - } - if (nodeDetails.size === 0 || selectedNodeId === nodeId) { - selectedNodeId = null; - } -} - -function closeAllNodeDetails() { - while (nodeDetails.size) { - closeNodeDetails(); - } -} - -function resumeUpdate() { - updatePausedAt = null; -} - -// Store API - -export class AppStore extends Store { - - // keep at the top - getAppState() { - const cp = this.getControlPipe(); - return { - controlPipe: cp ? cp.toJS() : null, - nodeDetails: this.getNodeDetailsState().toJS(), - selectedNodeId, - pinnedMetricType, - topologyId: currentTopologyId, - topologyOptions: topologyOptions.toJS() // all options - }; - } - - getShowingHelp() { - return showingHelp; - } - - getActiveTopologyOptions() { - // options for current topology, sub-topologies share options with parent - if (currentTopology && currentTopology.get('parentId')) { - return topologyOptions.get(currentTopology.get('parentId')); - } - return topologyOptions.get(currentTopologyId); - } - - getAdjacentNodes(nodeId) { - let adjacentNodes = makeSet(); - - if (nodes.has(nodeId)) { - adjacentNodes = makeSet(nodes.getIn([nodeId, 'adjacency'])); - // fill up set with reverse edges - nodes.forEach((node, id) => { - if (node.get('adjacency') && node.get('adjacency').includes(nodeId)) { - adjacentNodes = adjacentNodes.add(id); - } - }); - } - - return adjacentNodes; - } - - getPinnedMetric() { - return pinnedMetric; - } - - getSelectedMetric() { - return selectedMetric; - } - - getAvailableCanvasMetrics() { - return availableCanvasMetrics; - } - - getAvailableCanvasMetricsTypes() { - return makeMap(this.getAvailableCanvasMetrics().map(m => [m.get('id'), m.get('label')])); - } - - getControlStatus() { - return controlStatus; - } - - getControlPipe() { - return controlPipes.last(); - } - - getCurrentTopology() { - if (!currentTopology) { - currentTopology = setTopology(currentTopologyId); - } - return currentTopology; - } - - getCurrentTopologyId() { - return currentTopologyId; - } - - getCurrentTopologyOptions() { - return currentTopology && currentTopology.get('options') || makeOrderedMap(); - } - - getCurrentTopologyUrl() { - return currentTopology && currentTopology.get('url'); - } - - getErrorUrl() { - return errorUrl; - } - - getHighlightedEdgeIds() { - return highlightedEdgeIds; - } - - getHighlightedNodeIds() { - return highlightedNodeIds; - } - - getHostname() { - return hostname; - } - - getNodeDetails() { - return nodeDetails; - } - - getNodeDetailsState() { - return nodeDetails.toIndexedSeq().map(details => ({ - id: details.id, label: details.label, topologyId: details.topologyId - })); - } - - getTopCardNodeId() { - return nodeDetails.last() && nodeDetails.last().id; - } - - getNodes() { - return nodes; - } - - getSelectedNodeId() { - return selectedNodeId; - } - - getTopologies() { - return topologies; - } - - getTopologyUrlsById() { - return topologyUrlsById; - } - - getUpdatePausedAt() { - return updatePausedAt; - } - - getVersion() { - return version; - } - - getVersionUpdate() { - return versionUpdate; - } - - getPlugins() { - return plugins; - } - - isForceRelayout() { - return forceRelayout; - } - - isRouteSet() { - return routeSet; - } - - isTopologiesLoaded() { - return topologiesLoaded; - } - - isTopologyEmpty() { - return currentTopology && currentTopology.get('stats') - && currentTopology.get('stats').get('node_count') === 0 && nodes.size === 0; - } - - isUpdatePaused() { - return updatePausedAt !== null; - } - - isWebsocketClosed() { - return websocketClosed; - } - - __onDispatch(payload) { - if (!payload.type) { - error('Payload missing a type!', payload); - } - - switch (payload.type) { - case ActionTypes.CHANGE_TOPOLOGY_OPTION: { - resumeUpdate(); - // set option on parent topology - const topology = findTopologyById(topologies, payload.topologyId); - if (topology) { - const topologyId = topology.get('parentId') || topology.get('id'); - if (topologyOptions.getIn([topologyId, payload.option]) !== payload.value) { - nodes = nodes.clear(); - } - topologyOptions = topologyOptions.setIn( - [topologyId, payload.option], - payload.value - ); - this.__emitChange(); - } - break; - } - case ActionTypes.CLEAR_CONTROL_ERROR: { - controlStatus = controlStatus.removeIn([payload.nodeId, 'error']); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_BACKGROUND: { - closeAllNodeDetails(); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_CLOSE_DETAILS: { - closeNodeDetails(payload.nodeId); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_CLOSE_TERMINAL: { - controlPipes = controlPipes.clear(); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_FORCE_RELAYOUT: { - forceRelayout = true; - // fire only once, reset after emitChange - setTimeout(() => { - forceRelayout = false; - }, 0); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_NODE: { - const prevSelectedNodeId = selectedNodeId; - const prevDetailsStackSize = nodeDetails.size; - // click on sibling closes all - closeAllNodeDetails(); - // select new node if it's not the same (in that case just delesect) - if (prevDetailsStackSize > 1 || prevSelectedNodeId !== payload.nodeId) { - // dont set origin if a node was already selected, suppresses animation - const origin = prevSelectedNodeId === null ? payload.origin : null; - nodeDetails = nodeDetails.set( - payload.nodeId, - { - id: payload.nodeId, - label: payload.label, - origin, - topologyId: currentTopologyId - } - ); - selectedNodeId = payload.nodeId; - } - this.__emitChange(); - break; - } - case ActionTypes.CLICK_PAUSE_UPDATE: { - updatePausedAt = new Date; - this.__emitChange(); - break; - } - case ActionTypes.CLICK_RELATIVE: { - if (nodeDetails.has(payload.nodeId)) { - // bring to front - const details = nodeDetails.get(payload.nodeId); - nodeDetails = nodeDetails.delete(payload.nodeId); - nodeDetails = nodeDetails.set(payload.nodeId, details); - } else { - nodeDetails = nodeDetails.set( - payload.nodeId, - { - id: payload.nodeId, - label: payload.label, - origin: payload.origin, - topologyId: payload.topologyId - } - ); - } - this.__emitChange(); - break; - } - case ActionTypes.CLICK_RESUME_UPDATE: { - resumeUpdate(); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: { - resumeUpdate(); - nodeDetails = nodeDetails.filter((v, k) => k === payload.nodeId); - controlPipes = controlPipes.clear(); - selectedNodeId = payload.nodeId; - if (payload.topologyId !== currentTopologyId) { - setTopology(payload.topologyId); - nodes = nodes.clear(); - } - availableCanvasMetrics = makeList(); - this.__emitChange(); - break; - } - case ActionTypes.CLICK_TOPOLOGY: { - resumeUpdate(); - closeAllNodeDetails(); - if (payload.topologyId !== currentTopologyId) { - setTopology(payload.topologyId); - nodes = nodes.clear(); - } - availableCanvasMetrics = makeList(); - this.__emitChange(); - break; - } - case ActionTypes.CLOSE_WEBSOCKET: { - if (!websocketClosed) { - websocketClosed = true; - this.__emitChange(); - } - break; - } - case ActionTypes.SELECT_METRIC: { - selectedMetric = payload.metricId; - this.__emitChange(); - break; - } - case ActionTypes.PIN_METRIC: { - pinnedMetric = payload.metricId; - pinnedMetricType = this.getAvailableCanvasMetricsTypes().get(payload.metricId); - selectedMetric = payload.metricId; - this.__emitChange(); - break; - } - case ActionTypes.UNPIN_METRIC: { - pinnedMetric = null; - pinnedMetricType = null; - this.__emitChange(); - break; - } - case ActionTypes.SHOW_HELP: { - showingHelp = true; - this.__emitChange(); - break; - } - case ActionTypes.HIDE_HELP: { - showingHelp = false; - this.__emitChange(); - break; - } - case ActionTypes.DESELECT_NODE: { - closeNodeDetails(); - this.__emitChange(); - break; - } - case ActionTypes.DO_CONTROL: { - controlStatus = controlStatus.set(payload.nodeId, makeMap({ - pending: true, - error: null - })); - this.__emitChange(); - break; - } - case ActionTypes.ENTER_EDGE: { - // clear old highlights - highlightedNodeIds = highlightedNodeIds.clear(); - highlightedEdgeIds = highlightedEdgeIds.clear(); - - // highlight edge - highlightedEdgeIds = highlightedEdgeIds.add(payload.edgeId); - - // highlight adjacent nodes - highlightedNodeIds = highlightedNodeIds.union(payload.edgeId.split(EDGE_ID_SEPARATOR)); - - this.__emitChange(); - break; - } - case ActionTypes.ENTER_NODE: { - const nodeId = payload.nodeId; - const adjacentNodes = this.getAdjacentNodes(nodeId); - - // clear old highlights - highlightedNodeIds = highlightedNodeIds.clear(); - highlightedEdgeIds = highlightedEdgeIds.clear(); - - // highlight nodes - highlightedNodeIds = highlightedNodeIds.add(nodeId); - highlightedNodeIds = highlightedNodeIds.union(adjacentNodes); - - // highlight edges - if (adjacentNodes.size > 0) { - // all neighbour combinations because we dont know which direction exists - highlightedEdgeIds = highlightedEdgeIds.union(adjacentNodes.flatMap((adjacentId) => [ - [adjacentId, nodeId].join(EDGE_ID_SEPARATOR), - [nodeId, adjacentId].join(EDGE_ID_SEPARATOR) - ])); - } - - this.__emitChange(); - break; - } - case ActionTypes.LEAVE_EDGE: { - highlightedEdgeIds = highlightedEdgeIds.clear(); - highlightedNodeIds = highlightedNodeIds.clear(); - this.__emitChange(); - break; - } - case ActionTypes.LEAVE_NODE: { - highlightedEdgeIds = highlightedEdgeIds.clear(); - highlightedNodeIds = highlightedNodeIds.clear(); - this.__emitChange(); - break; - } - case ActionTypes.OPEN_WEBSOCKET: { - // flush nodes cache after re-connect - nodes = nodes.clear(); - websocketClosed = false; - - this.__emitChange(); - break; - } - case ActionTypes.DO_CONTROL_ERROR: { - controlStatus = controlStatus.set(payload.nodeId, makeMap({ - pending: false, - error: payload.error - })); - this.__emitChange(); - break; - } - case ActionTypes.DO_CONTROL_SUCCESS: { - controlStatus = controlStatus.set(payload.nodeId, makeMap({ - pending: false, - error: null - })); - this.__emitChange(); - break; - } - case ActionTypes.RECEIVE_CONTROL_NODE_REMOVED: { - closeNodeDetails(payload.nodeId); - this.__emitChange(); - break; - } - case ActionTypes.RECEIVE_CONTROL_PIPE: { - controlPipes = controlPipes.set(payload.pipeId, makeOrderedMap({ - id: payload.pipeId, - nodeId: payload.nodeId, - raw: payload.rawTty - })); - this.__emitChange(); - break; - } - case ActionTypes.RECEIVE_CONTROL_PIPE_STATUS: { - if (controlPipes.has(payload.pipeId)) { - controlPipes = controlPipes.setIn([payload.pipeId, 'status'], payload.status); - this.__emitChange(); - } - break; - } - case ActionTypes.RECEIVE_ERROR: { - if (errorUrl !== null) { - errorUrl = payload.errorUrl; - this.__emitChange(); - } - break; - } - case ActionTypes.RECEIVE_NODE_DETAILS: { - errorUrl = null; - - // disregard if node is not selected anymore - if (nodeDetails.has(payload.details.id)) { - nodeDetails = nodeDetails.update(payload.details.id, obj => { - const result = Object.assign({}, obj); - result.notFound = false; - result.details = payload.details; - return result; - }); - } - this.__emitChange(); - break; - } - case ActionTypes.RECEIVE_NODES_DELTA: { - const emptyMessage = !payload.delta.add && !payload.delta.remove - && !payload.delta.update; - // this action is called frequently, good to check if something changed - const emitChange = !emptyMessage || errorUrl !== null; - - if (!emptyMessage) { - log('RECEIVE_NODES_DELTA', - 'remove', _.size(payload.delta.remove), - 'update', _.size(payload.delta.update), - 'add', _.size(payload.delta.add)); - } - - errorUrl = null; - - // nodes that no longer exist - _.each(payload.delta.remove, (nodeId) => { - // in case node disappears before mouseleave event - if (mouseOverNodeId === nodeId) { - mouseOverNodeId = null; - } - if (nodes.has(nodeId) && _.includes(mouseOverEdgeId, nodeId)) { - mouseOverEdgeId = null; - } - nodes = nodes.delete(nodeId); - }); - - // update existing nodes - _.each(payload.delta.update, (node) => { - if (nodes.has(node.id)) { - nodes = nodes.set(node.id, nodes.get(node.id).merge(fromJS(node))); - } - }); - - // add new nodes - _.each(payload.delta.add, (node) => { - nodes = nodes.set(node.id, fromJS(makeNode(node))); - }); - - availableCanvasMetrics = nodes - .valueSeq() - .flatMap(n => (n.get('metrics') || makeList()).map(m => ( - makeMap({id: m.get('id'), label: m.get('label')}) - ))) - .toSet() - .toList() - .sortBy(m => m.get('label')); - - const similarTypeMetric = availableCanvasMetrics - .find(m => m.get('label') === pinnedMetricType); - pinnedMetric = similarTypeMetric && similarTypeMetric.get('id'); - // if something in the current topo is not already selected, select it. - if (!availableCanvasMetrics.map(m => m.get('id')).toSet().has(selectedMetric)) { - selectedMetric = pinnedMetric; - } - - if (emitChange) { - this.__emitChange(); - } - break; - } - case ActionTypes.RECEIVE_NOT_FOUND: { - if (nodeDetails.has(payload.nodeId)) { - nodeDetails = nodeDetails.update(payload.nodeId, obj => { - const result = Object.assign({}, obj); - result.notFound = true; - return result; - }); - this.__emitChange(); - } - break; - } - case ActionTypes.RECEIVE_TOPOLOGIES: { - errorUrl = null; - topologyUrlsById = topologyUrlsById.clear(); - processTopologies(payload.topologies); - setTopology(currentTopologyId); - // only set on first load, if options are not already set via route - if (!topologiesLoaded && topologyOptions.size === 0) { - setDefaultTopologyOptions(topologies); - } - topologiesLoaded = true; - - this.__emitChange(); - break; - } - case ActionTypes.RECEIVE_API_DETAILS: { - errorUrl = null; - hostname = payload.hostname; - version = payload.version; - plugins = payload.plugins; - versionUpdate = payload.newVersion; - this.__emitChange(); - break; - } - case ActionTypes.ROUTE_TOPOLOGY: { - routeSet = true; - if (currentTopologyId !== payload.state.topologyId) { - nodes = nodes.clear(); - } - setTopology(payload.state.topologyId); - setDefaultTopologyOptions(topologies); - selectedNodeId = payload.state.selectedNodeId; - pinnedMetricType = payload.state.pinnedMetricType; - if (payload.state.controlPipe) { - controlPipes = makeOrderedMap({ - [payload.state.controlPipe.id]: - makeOrderedMap(payload.state.controlPipe) - }); - } else { - controlPipes = controlPipes.clear(); - } - if (payload.state.nodeDetails) { - const payloadNodeDetails = makeOrderedMap( - payload.state.nodeDetails.map(obj => [obj.id, obj])); - // check if detail IDs have changed - if (!isDeepEqual(nodeDetails.keySeq(), payloadNodeDetails.keySeq())) { - nodeDetails = payloadNodeDetails; - } - } else { - nodeDetails = nodeDetails.clear(); - } - topologyOptions = fromJS(payload.state.topologyOptions) - || topologyOptions; - this.__emitChange(); - break; - } - default: { - break; - } - } - } -} - -export default new AppStore(AppDispatcher); diff --git a/client/app/scripts/stores/configureStore.dev.js b/client/app/scripts/stores/configureStore.dev.js new file mode 100644 index 000000000..e0b838959 --- /dev/null +++ b/client/app/scripts/stores/configureStore.dev.js @@ -0,0 +1,28 @@ +import { createStore, applyMiddleware, compose } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +// import createLogger from 'redux-logger'; + +import DevTools from '../components/dev-tools'; +import { initialState, rootReducer } from '../reducers/root'; + +export default function configureStore() { + const store = createStore( + rootReducer, + initialState, + compose( + // applyMiddleware(thunkMiddleware, createLogger()), + applyMiddleware(thunkMiddleware), + DevTools.instrument() + ) + ); + + if (module.hot) { + // Enable Webpack hot module replacement for reducers + module.hot.accept('../reducers/root', () => { + const nextRootReducer = require('../reducers/root').default; + store.replaceReducer(nextRootReducer); + }); + } + + return store; +} diff --git a/client/app/scripts/stores/configureStore.js b/client/app/scripts/stores/configureStore.js new file mode 100644 index 000000000..f61d8a847 --- /dev/null +++ b/client/app/scripts/stores/configureStore.js @@ -0,0 +1,12 @@ +import { createStore, applyMiddleware } from 'redux'; +import thunkMiddleware from 'redux-thunk'; + +import { initialState, rootReducer } from '../reducers/root'; + +export default function configureStore() { + return createStore( + rootReducer, + initialState, + applyMiddleware(thunkMiddleware) + ); +} diff --git a/client/app/scripts/terminal-main.js b/client/app/scripts/terminal-main.js index 9e651ade8..3feeee7e4 100644 --- a/client/app/scripts/terminal-main.js +++ b/client/app/scripts/terminal-main.js @@ -1,9 +1,19 @@ require('../styles/main.less'); require('../images/favicon.ico'); +import 'babel-polyfill'; import React from 'react'; import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; -import { TerminalApp } from './components/terminal-app.js'; +import configureStore from './stores/configureStore'; +import TerminalApp from './components/terminal-app.js'; -ReactDOM.render(, document.getElementById('app')); +const store = configureStore(); + +ReactDOM.render( + + + , + document.getElementById('app') +); diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index 453249456..85cfc7d1f 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -1,7 +1,6 @@ import page from 'page'; import { route } from '../actions/app-actions'; -import AppStore from '../stores/app-store'; // // page.js won't match the routes below if ":state" has a slash in it, so replace those before we @@ -27,8 +26,24 @@ function shouldReplaceState(prevState, nextState) { return terminalToTerminal || closingTheTerminal; } -export function updateRoute() { - const state = AppStore.getAppState(); +export function getUrlState(state) { + const cp = state.get('controlPipes').last(); + const nodeDetails = state.get('nodeDetails').toIndexedSeq().map(details => ({ + id: details.id, label: details.label, topologyId: details.topologyId + })); + + return { + controlPipe: cp ? cp.toJS() : null, + nodeDetails: nodeDetails.toJS(), + selectedNodeId: state.get('selectedNodeId'), + pinnedMetricType: state.get('pinnedMetricType'), + topologyId: state.get('currentTopologyId'), + topologyOptions: state.get('topologyOptions').toJS() // all options + }; +} + +export function updateRoute(getState) { + const state = getUrlState(getState()); const stateUrl = encodeURL(JSON.stringify(state)); const dispatch = false; const urlStateString = window.location.hash @@ -44,17 +59,19 @@ export function updateRoute() { } } -page('/', () => { - updateRoute(); -}); -page('/state/:state', (ctx) => { - const state = JSON.parse(ctx.params.state); - route(state); -}); - -export function getRouter() { +export function getRouter(dispatch, initialState) { // strip any trailing '/'s. page.base(window.location.pathname.replace(/\/$/, '')); + + page('/', () => { + dispatch(route(initialState)); + }); + + page('/state/:state', (ctx) => { + const state = JSON.parse(ctx.params.state); + dispatch(route(state)); + }); + return page; } diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index 8c561285b..37307cdd6 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { is as isDeepEqual, Map as makeMap, Set as makeSet } from 'immutable'; /** * Returns a cache ID based on the topologyId and optionsQuery @@ -66,14 +67,16 @@ export function updateTopologyIds(topologies, parentId) { // map for easy lookup export function setTopologyUrlsById(topologyUrlsById, topologies) { let urlMap = topologyUrlsById; - topologies.forEach(topology => { - urlMap = urlMap.set(topology.id, topology.url); - if (topology.sub_topologies) { - topology.sub_topologies.forEach(subTopology => { - urlMap = urlMap.set(subTopology.id, subTopology.url); - }); - } - }); + if (topologies) { + topologies.forEach(topology => { + urlMap = urlMap.set(topology.id, topology.url); + if (topology.sub_topologies) { + topology.sub_topologies.forEach(subTopology => { + urlMap = urlMap.set(subTopology.id, subTopology.url); + }); + } + }); + } return urlMap; } @@ -81,3 +84,56 @@ export function filterHiddenTopologies(topologies) { return topologies.filter(t => (!t.hide_if_empty || t.stats.node_count > 0 || t.stats.filtered_nodes > 0)); } + +export function getActiveTopologyOptions(state) { + // options for current topology, sub-topologies share options with parent + const parentId = state.getIn(['currentTopology', 'parentId']); + if (parentId) { + return state.getIn(['topologyOptions', parentId]); + } + return state.getIn(['topologyOptions', state.get('currentTopologyId')]); +} + +export function getCurrentTopologyOptions(state) { + return state.getIn(['currentTopology', 'options']); +} + +export function isTopologyEmpty(state) { + return state.getIn(['currentTopology', 'stats', 'node_count'], 0) === 0 + && state.get('nodes').size === 0; +} + +export function getAdjacentNodes(state, originNodeId) { + let adjacentNodes = makeSet(); + const nodeId = originNodeId || state.get('selectedNodeId'); + + if (nodeId) { + if (state.hasIn(['nodes', nodeId])) { + adjacentNodes = makeSet(state.getIn(['nodes', nodeId, 'adjacency'])); + // fill up set with reverse edges + state.get('nodes').forEach((node, id) => { + if (node.get('adjacency') && node.get('adjacency').includes(nodeId)) { + adjacentNodes = adjacentNodes.add(id); + } + }); + } + } + + return adjacentNodes; +} + +export function hasSelectedNode(state) { + const selectedNodeId = state.get('selectedNodeId'); + return state.hasIn(['nodes', selectedNodeId]); +} + +export function getCurrentTopologyUrl(state) { + return state.getIn(['currentTopology', 'url']); +} + +export function isSameTopology(nodes, nextNodes) { + const mapper = node => makeMap({id: node.get('id'), adjacency: node.get('adjacency')}); + const topology = nodes.map(mapper); + const nextTopology = nextNodes.map(mapper); + return isDeepEqual(topology, nextTopology); +} diff --git a/client/app/scripts/utils/update-buffer-utils.js b/client/app/scripts/utils/update-buffer-utils.js index 52543695f..8f628aaea 100644 --- a/client/app/scripts/utils/update-buffer-utils.js +++ b/client/app/scripts/utils/update-buffer-utils.js @@ -3,7 +3,6 @@ import debug from 'debug'; import Immutable from 'immutable'; import { receiveNodesDelta } from '../actions/app-actions'; -import AppStore from '../stores/app-store'; const log = debug('scope:update-buffer-utils'); const makeList = Immutable.List; @@ -13,8 +12,8 @@ const bufferLength = 100; let deltaBuffer = makeList(); let updateTimer = null; -function isPaused() { - return AppStore.isUpdatePaused(); +function isPaused(getState) { + return getState().get('updatePausedAt') !== null; } export function resetUpdateBuffer() { @@ -22,8 +21,8 @@ export function resetUpdateBuffer() { deltaBuffer = deltaBuffer.clear(); } -function maybeUpdate() { - if (isPaused()) { +function maybeUpdate(getState) { + if (isPaused(getState)) { clearTimeout(updateTimer); resetUpdateBuffer(); } else { @@ -110,6 +109,6 @@ export function getUpdateBufferSize() { return deltaBuffer.size; } -export function resumeUpdate() { - maybeUpdate(); +export function resumeUpdate(getState) { + maybeUpdate(getState); } diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 885adb271..0f8810cf4 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -56,7 +56,7 @@ export function basePathSlash(urlPath) { const wsProto = location.protocol === 'https:' ? 'wss' : 'ws'; const wsUrl = `${wsProto}://${location.host}${basePath(location.pathname)}`; -function createWebsocket(topologyUrl, optionsQuery) { +function createWebsocket(topologyUrl, optionsQuery, dispatch) { if (socket) { socket.onclose = null; socket.onerror = null; @@ -68,67 +68,67 @@ function createWebsocket(topologyUrl, optionsQuery) { socket = new WebSocket(`${wsUrl}${topologyUrl}/ws?t=${updateFrequency}&${optionsQuery}`); socket.onopen = () => { - openWebsocket(); + dispatch(openWebsocket()); }; socket.onclose = () => { clearTimeout(reconnectTimer); log(`Closing websocket to ${topologyUrl}`, socket.readyState); socket = null; - closeWebsocket(); + dispatch(closeWebsocket()); reconnectTimer = setTimeout(() => { - createWebsocket(topologyUrl, optionsQuery); + createWebsocket(topologyUrl, optionsQuery, dispatch); }, reconnectTimerInterval); }; socket.onerror = () => { log(`Error in websocket to ${topologyUrl}`); - receiveError(currentUrl); + dispatch(receiveError(currentUrl)); }; socket.onmessage = (event) => { const msg = JSON.parse(event.data); - receiveNodesDelta(msg); + dispatch(receiveNodesDelta(msg)); }; } /* keep URLs relative */ -export function getTopologies(options) { +export function getTopologies(options, dispatch) { clearTimeout(topologyTimer); const optionsQuery = buildOptionsQuery(options); const url = `api/topology?${optionsQuery}`; reqwest({ url, success: (res) => { - receiveTopologies(res); + dispatch(receiveTopologies(res)); topologyTimer = setTimeout(() => { - getTopologies(options); + getTopologies(options, dispatch); }, TOPOLOGY_INTERVAL); }, error: (err) => { log(`Error in topology request: ${err.responseText}`); - receiveError(url); + dispatch(receiveError(url)); topologyTimer = setTimeout(() => { - getTopologies(options); + getTopologies(options, dispatch); }, TOPOLOGY_INTERVAL); } }); } -export function getNodesDelta(topologyUrl, options) { +export function getNodesDelta(topologyUrl, options, dispatch) { const optionsQuery = buildOptionsQuery(options); // only recreate websocket if url changed if (topologyUrl && (topologyUrl !== currentUrl || currentOptions !== optionsQuery)) { - createWebsocket(topologyUrl, optionsQuery); + createWebsocket(topologyUrl, optionsQuery, dispatch); currentUrl = topologyUrl; currentOptions = optionsQuery; } } -export function getNodeDetails(topologyUrlsById, nodeMap) { +export function getNodeDetails(topologyUrlsById, nodeMap, dispatch) { // get details for all opened nodes const obj = nodeMap.last(); if (obj && topologyUrlsById.has(obj.topologyId)) { @@ -140,42 +140,46 @@ export function getNodeDetails(topologyUrlsById, nodeMap) { success: (res) => { // make sure node is still selected if (nodeMap.has(res.node.id)) { - receiveNodeDetails(res.node); + dispatch(receiveNodeDetails(res.node)); } }, error: (err) => { log(`Error in node details request: ${err.responseText}`); // dont treat missing node as error if (err.status === 404) { - receiveNotFound(obj.id); + dispatch(receiveNotFound(obj.id)); } else { - receiveError(topologyUrl); + dispatch(receiveError(topologyUrl)); } } }); - } else { + } else if (obj) { log('No details or url found for ', obj); } } -export function getApiDetails() { +export function getApiDetails(dispatch) { clearTimeout(apiDetailsTimer); const url = 'api'; reqwest({ url, success: (res) => { - receiveApiDetails(res); - apiDetailsTimer = setTimeout(getApiDetails, API_INTERVAL); + dispatch(receiveApiDetails(res)); + apiDetailsTimer = setTimeout(() => { + getApiDetails(dispatch); + }, API_INTERVAL); }, error: (err) => { log(`Error in api details request: ${err.responseText}`); receiveError(url); - apiDetailsTimer = setTimeout(getApiDetails, API_INTERVAL / 2); + apiDetailsTimer = setTimeout(() => { + getApiDetails(dispatch); + }, API_INTERVAL / 2); } }); } -export function doControlRequest(nodeId, control) { +export function doControlRequest(nodeId, control, dispatch) { clearTimeout(controlErrorTimer); const url = `api/control/${encodeURIComponent(control.probeId)}/` + `${encodeURIComponent(control.nodeId)}/${control.id}`; @@ -183,26 +187,26 @@ export function doControlRequest(nodeId, control) { method: 'POST', url, success: (res) => { - receiveControlSuccess(nodeId); + dispatch(receiveControlSuccess(nodeId)); if (res) { if (res.pipe) { - receiveControlPipe(res.pipe, nodeId, res.raw_tty, true); + dispatch(receiveControlPipe(res.pipe, nodeId, res.raw_tty, true)); } if (res.removedNode) { - receiveControlNodeRemoved(nodeId); + dispatch(receiveControlNodeRemoved(nodeId)); } } }, error: (err) => { - receiveControlError(nodeId, err.response); + dispatch(receiveControlError(nodeId, err.response)); controlErrorTimer = setTimeout(() => { - clearControlError(nodeId); + dispatch(clearControlError(nodeId)); }, 10000); } }); } -export function deletePipe(pipeId) { +export function deletePipe(pipeId, dispatch) { const url = `api/pipe/${encodeURIComponent(pipeId)}`; reqwest({ method: 'DELETE', @@ -212,12 +216,12 @@ export function deletePipe(pipeId) { }, error: (err) => { log(`Error closing pipe:${err}`); - receiveError(url); + dispatch(receiveError(url)); } }); } -export function getPipeStatus(pipeId) { +export function getPipeStatus(pipeId, dispatch) { const url = `api/pipe/${encodeURIComponent(pipeId)}/check`; reqwest({ method: 'GET', @@ -233,7 +237,7 @@ export function getPipeStatus(pipeId) { return; } - receiveControlPipeStatus(pipeId, status); + dispatch(receiveControlPipeStatus(pipeId, status)); } }); } diff --git a/client/package.json b/client/package.json index fbcb34918..f09870513 100644 --- a/client/package.json +++ b/client/package.json @@ -6,12 +6,12 @@ "license": "Apache-2.0", "private": true, "dependencies": { - "classnames": "^2.2.1", + "babel-polyfill": "6.7.4", + "classnames": "~2.2.1", "d3": "~3.5.5", "dagre": "0.7.4", "debug": "~2.2.0", "filesize": "3.2.1", - "flux": "2.1.1", "font-awesome": "4.5.0", "font-awesome-webpack": "0.0.4", "immutable": "~3.7.4", @@ -19,15 +19,17 @@ "materialize-css": "0.97.5", "moment": "2.12.0", "page": "1.7.0", - "react": "^0.14.7", - "react-addons-pure-render-mixin": "^0.14.7", - "react-addons-transition-group": "^0.14.7", - "react-addons-update": "^0.14.7", - "react-dom": "^0.14.7", + "react": "^15.0.1", + "react-dom": "^15.0.1", "react-motion": "0.3.1", - "react-mixin": "^3.0.3", + "react-redux": "4.4.5", + "redux": "3.5.1", + "redux-immutable": "3.0.6", + "redux-logger": "2.6.1", + "redux-thunk": "2.0.1", "reqwest": "~2.0.5", - "timely": "0.1.0" + "timely": "0.1.0", + "whatwg-fetch": "0.11.0" }, "devDependencies": { "autoprefixer": "6.3.3", @@ -54,7 +56,10 @@ "less": "~2.6.1", "less-loader": "2.2.2", "postcss-loader": "0.8.2", - "react-addons-perf": "^0.14.0", + "react-addons-perf": "^15.0.1", + "redux-devtools": "^3.2.0", + "redux-devtools-dock-monitor": "^1.1.1", + "redux-devtools-log-monitor": "^1.0.11", "style-loader": "0.13.0", "url": "0.11.0", "url-loader": "0.5.7", diff --git a/client/webpack.local.config.js b/client/webpack.local.config.js index 4a58c9775..4698cb5cb 100644 --- a/client/webpack.local.config.js +++ b/client/webpack.local.config.js @@ -27,8 +27,12 @@ module.exports = { 'app': [ './app/scripts/main', 'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041', - 'webpack/hot/only-dev-server', - './app/scripts/debug' + 'webpack/hot/only-dev-server' + ], + 'dev-app': [ + './app/scripts/main.dev', + 'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041', + 'webpack/hot/only-dev-server' ], 'contrast-app': [ './app/scripts/contrast-main', @@ -40,8 +44,10 @@ module.exports = { 'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041', 'webpack/hot/only-dev-server' ], - vendors: ['classnames', 'd3', 'dagre', 'flux', 'immutable', - 'lodash', 'page', 'react', 'react-dom', 'react-motion'] + vendors: ['babel-polyfill', 'classnames', 'd3', 'dagre', 'filesize', + 'immutable', 'lodash', 'moment', 'page', 'react', + 'react-dom', 'react-motion', 'react-redux', 'redux', 'redux-thunk', + 'reqwest'] }, // This will not actually create a app.js file in ./build. It is used @@ -56,6 +62,7 @@ module.exports = { new webpack.optimize.CommonsChunkPlugin('vendors', 'vendors.js'), new webpack.HotModuleReplacementPlugin(), new webpack.NoErrorsPlugin(), + new webpack.IgnorePlugin(/^\.\/locale$/, [/moment$/]), new HtmlWebpackPlugin({ chunks: ['vendors', 'contrast-app'], template: 'app/html/index.html', @@ -66,6 +73,11 @@ module.exports = { template: 'app/html/index.html', filename: 'terminal.html' }), + new HtmlWebpackPlugin({ + chunks: ['vendors', 'dev-app'], + template: 'app/html/index.html', + filename: 'dev.html' + }), new HtmlWebpackPlugin({ chunks: ['vendors', 'app'], template: 'app/html/index.html', diff --git a/client/webpack.production.config.js b/client/webpack.production.config.js index 3b45e3990..21ad03ef0 100644 --- a/client/webpack.production.config.js +++ b/client/webpack.production.config.js @@ -24,8 +24,10 @@ module.exports = { app: './app/scripts/main', 'contrast-app': './app/scripts/contrast-main', 'terminal-app': './app/scripts/terminal-main', - vendors: ['classnames', 'd3', 'dagre', 'flux', 'immutable', - 'lodash', 'page', 'react', 'react-dom', 'react-motion'] + // keep only some in here, to make vendors and app bundles roughly same size + vendors: ['babel-polyfill', 'classnames', 'd3', 'immutable', + 'lodash', 'react', 'react-dom', 'react-redux', + 'redux', 'redux-thunk'] }, output: { @@ -85,6 +87,7 @@ module.exports = { new webpack.DefinePlugin(GLOBALS), new webpack.optimize.CommonsChunkPlugin('vendors', 'vendors.js'), new webpack.optimize.OccurenceOrderPlugin(true), + new webpack.IgnorePlugin(/^\.\/locale$/, [/moment$/]), new webpack.optimize.UglifyJsPlugin({ sourceMap: false, compress: {