Migrate from Flux to Redux

* better state visibility
* pure state changes
* state debug panel (show: crtl-h, move: ctrl-w)
This commit is contained in:
David Kaltschmidt
2016-04-27 13:09:00 +02:00
parent 0f3b6bc497
commit 96aae9bc99
50 changed files with 2153 additions and 1921 deletions

View File

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

View File

@@ -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,122 @@ 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());
}
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 +131,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
);
};
}

View File

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

View File

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

View File

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

View File

@@ -10,19 +10,11 @@ export default class NodesChartElements extends React.Component {
const props = this.props;
return (
<g className="nodes-chart-elements" transform={props.transform}>
<NodesChartEdges layoutEdges={props.edges} selectedNodeId={props.selectedNodeId}
highlightedEdgeIds={props.highlightedEdgeIds}
hasSelectedNode={props.hasSelectedNode}
<NodesChartEdges layoutEdges={props.layoutEdges}
layoutPrecision={props.layoutPrecision} />
<NodesChartNodes layoutNodes={props.nodes} selectedNodeId={props.selectedNodeId}
selectedMetric={props.selectedMetric}
topCardNode={props.topCardNode}
highlightedNodeIds={props.highlightedNodeIds}
hasSelectedNode={props.hasSelectedNode}
adjacentNodes={props.adjacentNodes}
nodeScale={props.nodeScale} onNodeClick={props.onNodeClick}
<NodesChartNodes layoutNodes={props.layoutNodes} nodeScale={props.nodeScale}
scale={props.scale} selectedNodeScale={props.selectedNodeScale}
topologyId={props.topologyId} layoutPrecision={props.layoutPrecision} />
layoutPrecision={props.layoutPrecision} />
</g>
);
}

View File

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

View File

@@ -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 {
<g transform="translate(24,24) scale(0.25)">
<Logo />
</g>
<NodesChartElements
edges={edges}
nodes={nodes}
transform={transform}
adjacentNodes={this.props.adjacentNodes}
layoutPrecision={this.props.layoutPrecision}
selectedMetric={this.props.selectedMetric}
selectedNodeId={this.props.selectedNodeId}
highlightedEdgeIds={this.props.highlightedEdgeIds}
highlightedNodeIds={this.props.highlightedNodeIds}
hasSelectedNode={this.props.hasSelectedNode}
nodeScale={this.state.nodeScale}
scale={this.state.scale}
<NodesChartElements layoutNodes={nodes} layoutEdges={edges}
nodeScale={this.state.nodeScale} scale={scale} transform={transform}
selectedNodeScale={this.state.selectedNodeScale}
topCardNode={this.props.topCardNode}
topologyId={this.props.topologyId} />
layoutPrecision={this.props.layoutPrecision} />
</svg>
</div>
);
@@ -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);

View File

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

View File

@@ -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 (
<div className="app">
{showingDebugToolbar() && <DebugToolbar />}
{this.state.showingHelp && <HelpPanel />}
{showingHelp && <HelpPanel />}
{showingDetails && <Details nodes={this.state.nodes}
controlStatus={this.state.controlStatus}
details={this.state.nodeDetails} />}
{showingDetails && <Details />}
{showingTerminal && <EmbeddedTerminal
pipe={this.state.controlPipe}
details={this.state.nodeDetails} />}
{showingTerminal && <EmbeddedTerminal />}
<div className="header">
<div className="logo">
@@ -151,45 +104,35 @@ export default class App extends React.Component {
<Logo />
</svg>
</div>
<Topologies topologies={this.state.topologies}
currentTopology={this.state.currentTopology} />
<Topologies />
</div>
<Nodes
nodes={this.state.nodes}
highlightedNodeIds={this.state.highlightedNodeIds}
highlightedEdgeIds={this.state.highlightedEdgeIds}
detailsWidth={detailsWidth}
selectedNodeId={this.state.selectedNodeId}
topMargin={topMargin}
topCardNode={topCardNode}
selectedMetric={this.state.selectedMetric}
forceRelayout={this.state.forceRelayout}
topologyOptions={this.state.activeTopologyOptions}
topologyEmpty={this.state.topologyEmpty}
adjacentNodes={this.state.adjacentNodes}
topologyId={this.state.currentTopologyId} />
<Nodes />
<Sidebar>
<Status errorUrl={this.state.errorUrl} topology={this.state.currentTopology}
topologiesLoaded={this.state.topologiesLoaded}
websocketClosed={this.state.websocketClosed} />
{this.state.availableCanvasMetrics.count() > 0 && <MetricSelector
availableCanvasMetrics={this.state.availableCanvasMetrics}
pinnedMetric={this.state.pinnedMetric}
selectedMetric={this.state.selectedMetric}
/>}
<TopologyOptions options={this.state.currentTopologyOptions}
topologyId={this.state.currentTopologyId}
activeOptions={this.state.activeTopologyOptions} />
<Status />
{showingMetricsSelector && <MetricSelector />}
<TopologyOptions />
</Sidebar>
<Footer hostname={this.state.hostname} plugins={this.state.plugins}
updatePaused={this.state.updatePaused} updatePausedAt={this.state.updatePausedAt}
version={this.state.version} versionUpdate={this.state.versionUpdate} />
<Footer />
</div>
);
}
}
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);

View File

@@ -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 (
<div className="debug-panel">
<div>
<label>Add nodes </label>
<button onClick={() => addNodes(1)}>+1</button>
<button onClick={() => addNodes(10)}>+10</button>
<button onClick={() => this.addNodes(1)}>+1</button>
<button onClick={() => this.addNodes(10)}>+10</button>
<input type="number" onChange={this.onChange} value={this.state.nodesToAdd} />
<button onClick={() => addNodes(this.state.nodesToAdd)}>+</button>
<button onClick={() => addAllVariants()}>Variants</button>
<button onClick={() => addAllMetricVariants()}>Metric Variants</button>
<button onClick={() => addNodes(1, LOREM)}>Long name</button>
<button onClick={() => this.addNodes(this.state.nodesToAdd)}>+</button>
<button onClick={() => addAllVariants(this.props.dispatch)}>Variants</button>
<button onClick={() => addAllMetricVariants(availableCanvasMetrics, this.props.dispatch)}>
Metric Variants
</button>
<button onClick={() => this.addNodes(1, LOREM)}>Long name</button>
</div>
<div>
@@ -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);

View File

@@ -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 (
<div className="details">
{details.toIndexedSeq().map((obj, index) => <DetailsCard key={obj.id}
index={index} cardCount={details.size} nodes={nodes}
nodeControlStatus={controlStatus.get(obj.id)} {...obj} />
)}
</div>
);
class Details extends React.Component {
render() {
const { controlStatus, details } = this.props;
// render all details as cards, later cards go on top
return (
<div className="details">
{details.toIndexedSeq().map((obj, index) => <DetailsCard key={obj.id}
index={index} cardCount={details.size}
nodeControlStatus={controlStatus.get(obj.id)} {...obj} />
)}
</div>
);
}
}
function mapStateToProps(state) {
return {
controlStatus: state.get('controlStatus'),
details: state.get('nodeDetails')
};
}
export default connect(
mapStateToProps
)(Details);

View File

@@ -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(
<DockMonitor defaultIsVisible={false}
toggleVisibilityKey="ctrl-h" changePositionKey="ctrl-w">
<LogMonitor />
</DockMonitor>
);

View File

@@ -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 (
<div className="terminal-embedded" style={style}>
<Terminal key={pipe.get('id')} pipe={pipe} titleBarColor={titleBarColor}
statusBarColor={statusBarColor} containerMargin={style.right} title={title} />
</div>
);
// 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 (
<div className="terminal-embedded" style={style}>
<Terminal key={pipe.get('id')} pipe={pipe} titleBarColor={titleBarColor}
statusBarColor={statusBarColor} containerMargin={style.right}
title={title} />
</div>
);
}
}
function mapStateToProps(state) {
return {
details: state.get('nodeDetails'),
pipe: state.get('controlPipes').last()
};
}
export default connect(
mapStateToProps
)(EmeddedTerminal);

View File

@@ -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 (
<div className="footer">
<div className="footer-status">
{versionUpdate && <a className="footer-versionupdate"
title={versionUpdateTitle} href={versionUpdate.downloadUrl} target="_blank">
Update available: {versionUpdate.version}
</a>}
<span className="footer-label">Version</span>
{version}
<span className="footer-label">on</span>
{hostname}
</div>
<div className="footer-plugins">
<Plugins />
</div>
<div className="footer-tools">
<a className={pauseClassName} onClick={pauseAction} title={pauseTitle}>
{pauseLabel !== '' && <span className="footer-label">{pauseLabel}</span>}
<span className="fa fa-pause" />
</a>
<a className="footer-icon" onClick={this.props.clickForceRelayout}
title={forceRelayoutTitle}>
<span className="fa fa-refresh" />
</a>
<a className="footer-icon" onClick={this.props.clickDownloadGraph}
title="Save canvas as SVG">
<span className="fa fa-download" />
</a>
<a className="footer-icon" href="api/report" download title="Save raw data as JSON">
<span className="fa fa-code" />
</a>
<a className="footer-icon" href={otherContrastModeUrl} title={otherContrastModeTitle}>
<span className="fa fa-adjust" />
</a>
<a className="footer-icon" href="https://gitreports.com/issue/weaveworks/scope" target="_blank" title="Report an issue">
<span className="fa fa-bug" />
</a>
<a className="footer-icon" onClick={this.props.toggleHelp}
title="Show help">
<span className="fa fa-question" />
</a>
</div>
</div>
);
}
const versionUpdateTitle = versionUpdate
? `New version available: ${versionUpdate.version}. Click to download`
: '';
return (
<div className="footer">
<div className="footer-status">
{versionUpdate && <a className="footer-versionupdate"
title={versionUpdateTitle} href={versionUpdate.downloadUrl} target="_blank">
Update available: {versionUpdate.version}
</a>}
<span className="footer-label">Version</span>
{version}
<span className="footer-label">on</span>
{hostname}
</div>
<div className="footer-plugins">
<Plugins plugins={plugins} />
</div>
<div className="footer-tools">
<a className={pauseClassName} onClick={pauseAction} title={pauseTitle}>
{pauseLabel !== '' && <span className="footer-label">{pauseLabel}</span>}
<span className="fa fa-pause" />
</a>
<a className="footer-icon" onClick={clickForceRelayout} title={forceRelayoutTitle}>
<span className="fa fa-refresh" />
</a>
<a className="footer-icon" onClick={clickDownloadGraph} title="Save canvas as SVG">
<span className="fa fa-download" />
</a>
<a className="footer-icon" href="api/report" download title="Save raw data as JSON">
<span className="fa fa-code" />
</a>
<a className="footer-icon" href={otherContrastModeUrl} title={otherContrastModeTitle}>
<span className="fa fa-adjust" />
</a>
<a className="footer-icon" href="https://gitreports.com/issue/weaveworks/scope" target="_blank" title="Report an issue">
<span className="fa fa-bug" />
</a>
<a className="footer-icon" onClick={toggleHelp} title="Show help">
<span className="fa fa-question" />
</a>
</div>
</div>
);
}
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);

View File

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

View File

@@ -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 => (
<MetricSelectorItem key={metric.get('id')} metric={metric} {...this.props} />
<MetricSelectorItem key={metric.get('id')} metric={metric} />
));
return (
<div
className="metric-selector">
<div className="metric-selector">
<div className="metric-selector-wrapper" onMouseLeave={this.onMouseOut}>
{items}
</div>
@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="nodes-wrapper">
{topologyEmpty && errorEmpty}
<NodesChart {...this.props} {...this.state}
<NodesChart {...this.state}
detailsWidth={detailsWidth}
layoutPrecision={layoutPrecision}
hasSelectedNode={hasSelectedNode}
/>
@@ -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);

View File

@@ -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 (
<div className="plugins">
<span className="plugins-label">
Plugins:
</span>
{hasPlugins && this.props.plugins.map((plugin, index) => this.renderPlugin(plugin, index))}
{hasPlugins && this.props.plugins.toIndexedSeq()
.map((plugin, index) => this.renderPlugin(plugin, index))}
{!hasPlugins && <span className="plugins-empty">n/a</span>}
</div>
);
}
}
function mapStateToProps(state) {
return {
plugins: state.get('plugins')
};
}
export default connect(
mapStateToProps
)(Plugins);

View File

@@ -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 (
<div className={classNames}>
{showWarningIcon && <span className="status-icon fa fa-exclamation-circle" />}
<span className="status-label" title={title}>{text}</span>
</div>
);
return (
<div className={classNames}>
{showWarningIcon && <span className="status-icon fa fa-exclamation-circle" />}
<span className="status-label" title={title}>{text}</span>
</div>
);
}
}
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);

View File

@@ -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 (
<div className="terminal-app" style={style}>
{this.state.controlPipe && <Terminal
pipe={this.state.controlPipe}
{this.props.controlPipe && <Terminal
pipe={this.props.controlPipe}
titleBarColor={this.state.titleBarColor}
statusBarColor={this.state.statusBarColor}
title={this.state.title}
@@ -51,3 +35,14 @@ export class TerminalApp extends React.Component {
);
}
}
function mapStateToProps(state) {
return {
controlPipe: state.get('controlPipes').last()
};
}
export default connect(
mapStateToProps,
{ receiveControlPipeFromParams }
)(TerminalApp);

View File

@@ -2,6 +2,7 @@
import debug from 'debug';
import React from 'react';
import ReactDOM from 'react-dom';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { clickCloseTerminal } from '../actions/app-actions';
@@ -73,7 +74,7 @@ function openNewWindow(url, bcr, minWidth = 200) {
window.open(url, '', windowOptionsString);
}
export default class Terminal extends React.Component {
class Terminal extends React.Component {
constructor(props, context) {
super(props, context);
@@ -96,7 +97,7 @@ export default class Terminal extends React.Component {
const socket = new WebSocket(`${wsUrl}/api/pipe/${this.getPipeId()}`);
socket.binaryType = 'arraybuffer';
getPipeStatus(this.getPipeId());
getPipeStatus(this.getPipeId(), this.props.dispatch);
socket.onopen = () => {
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);

View File

@@ -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 {
</div>
<div className="topologies-sub">
{topology.has('sub_topologies')
&& topology.get('sub_topologies').map(this.renderSubTopology)}
&& topology.get('sub_topologies').map(subTop => this.renderSubTopology(subTop))}
</div>
</div>
);
@@ -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);

View File

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

View File

@@ -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 (
<div className="topology-options">
{this.props.options.toIndexedSeq().map(option => this.renderOption(option))}
{this.props.options && this.props.options.toIndexedSeq().map(
option => this.renderOption(option))}
</div>
);
}
}
reactMixin.onClass(TopologyOptions, PureRenderMixin);
function mapStateToProps(state) {
return {
options: getCurrentTopologyOptions(state),
topologyId: state.get('currentTopologyId'),
activeOptions: getActiveTopologyOptions(state)
};
}
export default connect(
mapStateToProps
)(TopologyOptions);

View File

@@ -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(<App />, document.getElementById('app'));
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('app')
);

View File

@@ -1,3 +0,0 @@
import Immutable from 'immutable';
import installDevTools from 'immutable-devtools';
installDevTools(Immutable);

View File

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

View File

@@ -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';
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(
<Provider store={store}>
<div>
<App />
<DevTools />
</div>
</Provider>,
document.getElementById('app')
);

View File

@@ -1,10 +1,5 @@
require('font-awesome-webpack');
require('../styles/main.less');
require('../images/favicon.ico');
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/app.js';
ReactDOM.render(<App />, document.getElementById('app'));
if (process.env.NODE_ENV === 'production') {
module.exports = require('./main.prod');
} else {
module.exports = require('./main.dev');
}

View File

@@ -0,0 +1,22 @@
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';
import App from './components/app';
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<div>
<App />
</div>
</Provider>,
document.getElementById('app')
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
if (process.env.NODE_ENV === 'production') {
module.exports = require('./configureStore.prod');
} else {
module.exports = require('./configureStore.dev');
}

View File

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

View File

@@ -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(<TerminalApp />, document.getElementById('app'));
const store = configureStore();
ReactDOM.render(
<Provider store={store}>
<TerminalApp />
</Provider>,
document.getElementById('app')
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
@@ -21,13 +21,17 @@
"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-motion": "0.3.1",
"react-mixin": "^3.0.3",
"react-motion": "0.3.1",
"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",
@@ -55,6 +59,9 @@
"less-loader": "2.2.2",
"postcss-loader": "0.8.2",
"react-addons-perf": "^0.14.0",
"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",

View File

@@ -27,8 +27,7 @@ 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'
],
'contrast-app': [
'./app/scripts/contrast-main',
@@ -40,8 +39,9 @@ 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', 'immutable',
'lodash', 'page', 'react', 'react-dom', 'react-redux', 'react-motion',
'redux', 'redux-thunk']
},
// This will not actually create a app.js file in ./build. It is used

View File

@@ -24,8 +24,9 @@ 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']
vendors: ['babel-polyfill', 'classnames', 'd3', 'dagre', 'immutable',
'lodash', 'page', 'react', 'react-dom', 'react-redux', 'react-motion',
'redux', 'redux-thunk']
},
output: {