diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 792d9b821..b9348dd23 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -32,6 +32,53 @@ export function toggleHelp() { }; } +// +// Networks +// + + +export function showNetworks(visible) { + return { + type: ActionTypes.SHOW_NETWORKS, + visible + }; +} + + +export function selectNetwork(networkId) { + return { + type: ActionTypes.SELECT_NETWORK, + networkId + }; +} + +export function pinNetwork(networkId) { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.PIN_NETWORK, + networkId, + }); + + updateRoute(getState); + }; +} + +export function unpinNetwork(networkId) { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.UNPIN_NETWORK, + networkId, + }); + + updateRoute(getState); + }; +} + + +// +// Metrics +// + export function selectMetric(metricId) { return { type: ActionTypes.SELECT_METRIC, diff --git a/client/app/scripts/charts/node-networks-overlay.js b/client/app/scripts/charts/node-networks-overlay.js new file mode 100644 index 000000000..b728de051 --- /dev/null +++ b/client/app/scripts/charts/node-networks-overlay.js @@ -0,0 +1,47 @@ +import React from 'react'; +import d3 from 'd3'; +import { List as makeList } from 'immutable'; +import { getNodeColor } from '../utils/color-utils'; +import { isContrastMode } from '../utils/contrast-utils'; + + +const padding = 0.05; +const offset = Math.PI; +const arc = d3.svg.arc() + .startAngle(d => d.startAngle + offset) + .endAngle(d => d.endAngle + offset); +const arcScale = d3.scale.linear() + .range([Math.PI * 0.25 + padding, Math.PI * 1.75 - padding]); + + +function NodeNetworksOverlay({size, stack, networks = makeList()}) { + arcScale.domain([0, networks.size]); + const radius = size * 0.9; + + const paths = networks.map((n, i) => { + const d = arc({ + padAngle: 0.05, + innerRadius: radius, + outerRadius: radius + 4, + startAngle: arcScale(i), + endAngle: arcScale(i + 1) + }); + + return (); + }); + + let transform = ''; + if (stack) { + const contrastMode = isContrastMode(); + const [dx, dy] = contrastMode ? [0, 8] : [0, 5]; + transform = `translate(${dx}, ${dy * -1.5})`; + } + + return ( + + {paths.toJS()} + + ); +} + +export default NodeNetworksOverlay; diff --git a/client/app/scripts/charts/node-shape-hex.js b/client/app/scripts/charts/node-shape-hex.js index ce5f84f00..d7045c9ab 100644 --- a/client/app/scripts/charts/node-shape-hex.js +++ b/client/app/scripts/charts/node-shape-hex.js @@ -30,14 +30,12 @@ function getPoints(h) { } -export default function NodeShapeHex({id, highlighted, size, color, metric, networks}) { +export default function NodeShapeHex({id, highlighted, size, color, metric}) { const pathProps = v => ({ d: getPoints(size * v * 2), transform: `rotate(90) translate(-${size * getWidth(v)}, -${size * v})` }); - console.log('netz', networks); - const shadowSize = 0.45; const upperHexBitHeight = -0.25 * size * shadowSize; diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index f28f52719..acd942702 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -15,6 +15,7 @@ import NodeShapeRoundedSquare from './node-shape-rounded-square'; import NodeShapeHex from './node-shape-hex'; import NodeShapeHeptagon from './node-shape-heptagon'; import NodeShapeCloud from './node-shape-cloud'; +import NodeNetworksOverlay from './node-networks-overlay'; function stackedShape(Shape) { const factory = React.createFactory(NodeShapeStack); @@ -75,8 +76,9 @@ class Node extends React.Component { } render() { - const { blurred, focused, highlighted, label, matches = makeMap(), - pseudo, rank, subLabel, scaleFactor, transform, zoomScale, exportingGraph } = this.props; + const { blurred, focused, highlighted, label, matches = makeMap(), networks, + pseudo, rank, subLabel, scaleFactor, transform, zoomScale, exportingGraph, + showingNetworks, stack } = this.props; const { hovered, matched } = this.state; const nodeScale = focused ? this.props.selectedNodeScale : this.props.nodeScale; @@ -100,11 +102,14 @@ class Node extends React.Component { const NodeShapeType = getNodeShape(this.props); const useSvgLabels = exportingGraph; - + const size = nodeScale(scaleFactor); return ( + {showingNetworks && } + {useSvgLabels ? svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) : @@ -124,7 +129,7 @@ class Node extends React.Component { @@ -152,7 +157,8 @@ class Node extends React.Component { export default connect( state => ({ searchQuery: state.get('searchQuery'), - exportingGraph: state.get('exportingGraph') + exportingGraph: state.get('exportingGraph'), + showingNetworks: state.get('showingNetworks'), }), { clickNode, enterNode, leaveNode } )(Node); diff --git a/client/app/scripts/charts/nodes-chart-nodes.js b/client/app/scripts/charts/nodes-chart-nodes.js index 215509628..11d6ed6ee 100644 --- a/client/app/scripts/charts/nodes-chart-nodes.js +++ b/client/app/scripts/charts/nodes-chart-nodes.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { fromJS, Map as makeMap } from 'immutable'; +import { fromJS, Map as makeMap, List as makeList } from 'immutable'; import { getAdjacentNodes } from '../utils/topology-utils'; import NodeContainer from './node-container'; @@ -9,7 +9,7 @@ class NodesChartNodes extends React.Component { render() { const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision, mouseOverNodeId, nodeScale, scale, searchNodeMatches = makeMap(), - searchQuery, selectedMetric, selectedNodeScale, selectedNodeId, + searchQuery, selectedMetric, selectedNetwork, selectedNodeScale, selectedNodeId, topCardNode } = this.props; const zoomScale = scale; @@ -23,7 +23,8 @@ class NodesChartNodes extends React.Component { const setBlurred = node => node.set('blurred', selectedNodeId && !node.get('focused') || searchQuery && !searchNodeMatches.has(node.get('id')) - && !node.get('highlighted')); + && !node.get('highlighted') + || selectedNetwork && !(node.get('networks') || makeList()).contains(selectedNetwork)); // make sure blurred nodes are in the background const sortNodes = node => { @@ -91,6 +92,7 @@ function mapStateToProps(state) { highlightedNodeIds: state.get('highlightedNodeIds'), mouseOverNodeId: state.get('mouseOverNodeId'), selectedMetric: state.get('selectedMetric'), + selectedNetwork: state.get('selectedNetwork'), selectedNodeId: state.get('selectedNodeId'), searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]), searchQuery: state.get('searchQuery'), diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index ef23dae70..29bb8eb94 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -179,7 +179,7 @@ class NodesChart extends React.Component { rank: node.get('rank'), shape: node.get('shape'), stack: node.get('stack'), - networks: node.get('networknetworks'), + networks: node.get('networks'), })); }); diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 11f27b0f0..2568c9ea0 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -16,6 +16,7 @@ import { focusSearch, pinNextMetric, hitBackspace, hitEnter, hitEsc, unpinMetric import Details from './details'; import Nodes from './nodes'; import MetricSelector from './metric-selector'; +import NetworkSelector from './networks-selector'; import EmbeddedTerminal from './embedded-terminal'; import { getRouter } from '../utils/router-utils'; import DebugToolbar, { showingDebugToolbar, @@ -97,7 +98,8 @@ class App extends React.Component { } render() { - const { showingDetails, showingHelp, showingMetricsSelector, showingTerminal } = this.props; + const { showingDetails, showingHelp, showingMetricsSelector, showingNetworkSelector, + showingTerminal } = this.props; return (
@@ -124,6 +126,7 @@ class App extends React.Component { {showingMetricsSelector && } + {showingNetworkSelector && } @@ -142,6 +145,7 @@ function mapStateToProps(state) { showingDetails: state.get('nodeDetails').size > 0, showingHelp: state.get('showingHelp'), showingMetricsSelector: state.get('availableCanvasMetrics').count() > 0, + showingNetworkSelector: state.get('availableNetworks').count() > 0, showingTerminal: state.get('controlPipes').size > 0, urlState: getUrlState(state) }; diff --git a/client/app/scripts/components/debug-toolbar.js b/client/app/scripts/components/debug-toolbar.js index e36bc506e..e52e22876 100644 --- a/client/app/scripts/components/debug-toolbar.js +++ b/client/app/scripts/components/debug-toolbar.js @@ -1,5 +1,6 @@ /* eslint react/jsx-no-bind: "off" */ import React from 'react'; +import d3 from 'd3'; import _ from 'lodash'; import Perf from 'react-addons-perf'; import { connect } from 'react-redux'; @@ -9,13 +10,14 @@ import debug from 'debug'; const log = debug('scope:debug-panel'); import { receiveNodesDelta } from '../actions/app-actions'; -import { getNodeColor, getNodeColorDark } from '../utils/color-utils'; +import { getNodeColor, getNodeColorDark, text2degree } from '../utils/color-utils'; const SHAPES = ['square', 'hexagon', 'heptagon', 'circle']; const NODE_COUNTS = [1, 2, 3]; const STACK_VARIANTS = [false, true]; const METRIC_FILLS = [0, 0.1, 50, 99.9, 100]; +const NETWORKS = ['be', 'fe', 'lb', 'db']; const LOREM = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation @@ -23,7 +25,7 @@ ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor i voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`; -const sample = (collection) => _.range(_.random(4)).map(() => _.sample(collection)); +const sample = (collection, n = 4) => _.sampleSize(collection, _.random(n)); const shapeTypes = { @@ -41,7 +43,10 @@ const LABEL_PREFIXES = _.range('A'.charCodeAt(), 'Z'.charCodeAt() + 1) // const randomLetter = () => _.sample(LABEL_PREFIXES); -const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCount = 1) => ({ +const deltaAdd = ( + name, adjacency = [], shape = 'circle', stack = false, nodeCount = 1, + networks = ['fe', 'be'] +) => ({ adjacency, controls: {}, shape, @@ -53,7 +58,7 @@ const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCou latest: {}, origins: [], rank: name, - networks: ['fe', 'be'], + networks }); @@ -170,7 +175,8 @@ class DebugToolbar extends React.Component { sample(allNodes), _.sample(SHAPES), _.sample(STACK_VARIANTS), - _.sample(NODE_COUNTS) + _.sample(NODE_COUNTS), + sample(NETWORKS, 3) )) })); @@ -208,8 +214,21 @@ class DebugToolbar extends React.Component {
- {this.state.showColors && [getNodeColor, getNodeColorDark].map(fn => ( - + {this.state.showColors && +
+ + {LABEL_PREFIXES.map(r => ( + + + ))} + +
+
} + + {this.state.showColors && [getNodeColor, getNodeColorDark].map((fn, i) => ( + {LABEL_PREFIXES.map(r => ( diff --git a/client/app/scripts/components/network-selector-item.js b/client/app/scripts/components/network-selector-item.js new file mode 100644 index 000000000..d0bc5557f --- /dev/null +++ b/client/app/scripts/components/network-selector-item.js @@ -0,0 +1,69 @@ +import React from 'react'; +import classNames from 'classnames'; +import { connect } from 'react-redux'; + +import { selectNetwork, pinNetwork, unpinNetwork } from '../actions/app-actions'; +import { getNodeColor } from '../utils/color-utils'; + +class NetworkSelectorItem extends React.Component { + + constructor(props, context) { + super(props, context); + + this.onMouseOver = this.onMouseOver.bind(this); + this.onMouseClick = this.onMouseClick.bind(this); + } + + onMouseOver() { + const k = this.props.network.get('id'); + this.props.selectNetwork(k); + } + + onMouseClick() { + const k = this.props.network.get('id'); + const pinnedNetwork = this.props.pinnedNetwork; + + if (k === pinnedNetwork) { + this.props.unpinNetwork(k); + } else { + this.props.pinNetwork(k); + } + } + + render() { + const {network, selectedNetwork, pinnedNetwork} = this.props; + const id = network.get('id'); + const isPinned = (id === pinnedNetwork); + const isSelected = (id === selectedNetwork); + const className = classNames('metric-selector-action', { + 'metric-selector-action-selected': isSelected + }); + const style = { + backgroundColor: getNodeColor(id) + }; + + return ( +
+ {network.get('label')} + {isPinned && } +
+ ); + } +} + +function mapStateToProps(state) { + return { + selectedNetwork: state.get('selectedNetwork'), + pinnedNetwork: state.get('pinnedNetwork') + }; +} + +export default connect( + mapStateToProps, + { selectNetwork, pinNetwork, unpinNetwork } +)(NetworkSelectorItem); diff --git a/client/app/scripts/components/networks-selector.js b/client/app/scripts/components/networks-selector.js new file mode 100644 index 000000000..2653478ec --- /dev/null +++ b/client/app/scripts/components/networks-selector.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import classNames from 'classnames'; + +import { selectNetwork, showNetworks } from '../actions/app-actions'; +import NetworkSelectorItem from './network-selector-item'; + +class NetworkSelector extends React.Component { + + constructor(props, context) { + super(props, context); + this.onClick = this.onClick.bind(this); + this.onMouseOut = this.onMouseOut.bind(this); + } + + onClick() { + return this.props.showNetworks(!this.props.showingNetworks); + } + + onMouseOut() { + this.props.selectNetwork(this.props.pinnedNetwork); + } + + render() { + const { availableNetworks, showingNetworks } = this.props; + + const items = availableNetworks.map(network => ( + + )); + + const className = classNames('metric-selector-action', { + 'metric-selector-action-selected': showingNetworks + }); + + return ( +
+
+
+ Networks +
+ {showingNetworks && items} +
+
+ ); + } +} + +function mapStateToProps(state) { + return { + availableNetworks: state.get('availableNetworks'), + showingNetworks: state.get('showingNetworks'), + pinnedNetwork: state.get('pinnedNetwork') + }; +} + +export default connect( + mapStateToProps, + { selectNetwork, showNetworks } +)(NetworkSelector); diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 5d2b85277..788a274f5 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -47,7 +47,12 @@ const ACTION_TYPES = [ 'ROUTE_TOPOLOGY', 'SELECT_METRIC', 'SHOW_HELP', - 'SET_EXPORTING_GRAPH' + 'SET_EXPORTING_GRAPH', + + 'SELECT_NETWORK', + 'PIN_NETWORK', + 'UNPIN_NETWORK', + 'SHOW_NETWORKS', ]; export default _.zipObject(ACTION_TYPES, ACTION_TYPES); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 244f09921..298ee2909 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -20,6 +20,7 @@ const topologySorter = topology => topology.get('rank'); export const initialState = makeMap({ availableCanvasMetrics: makeList(), + availableNetworks: makeList(), controlPipes: makeOrderedMap(), // pipeId -> controlPipe controlStatus: makeMap(), currentTopology: null, @@ -39,6 +40,7 @@ export const initialState = makeMap({ // 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, + pinnedNetwork: null, plugins: makeList(), pinnedSearches: makeList(), // list of node filters routeSet: false, @@ -46,8 +48,10 @@ export const initialState = makeMap({ searchNodeMatches: makeMap(), searchQuery: null, selectedMetric: null, + selectedNetwork: null, selectedNodeId: null, showingHelp: false, + showingNetworks: false, topologies: makeList(), topologiesLoaded: false, topologyOptions: makeOrderedMap(), // topologyId -> options @@ -102,25 +106,46 @@ function setDefaultTopologyOptions(state, topologyList) { return state; } +function shouldCloseExisting(state) { + const nodeDetails = state.get('nodeDetails'); + if (nodeDetails.size > 0 && nodeDetails.valueSeq().last().topologyId === 'networks') { + return false; + } + return true; +} + 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 (nodeDetails.size === 0) { + return state; } - if (state.get('nodeDetails').size === 0 || state.get('selectedNodeId') === nodeId) { + nodeId = 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') !== nodeId)); + state = state.deleteIn(['nodeDetails', nodeId]); + + // FIXME: duplicated state in a sense, look into reselect or something, we could derive the + // selectedNetwork from the contents of nodeDetails + // + // clear this additional state + if (state.get('selectedNetwork') === nodeId) { + state = state.set('selectedNetwork', null); + state = state.set('pinnedNetwork', null); + } + + // TODO: could also be derived state. + if (state.get('selectedNodeId') === nodeId) { state = state.set('selectedNodeId', null); } return state; } function closeAllNodeDetails(state) { - while (state.get('nodeDetails').size) { - state = closeNodeDetails(state); - } + state.get('nodeDetails').keySeq().forEach(nodeId => { + state = closeNodeDetails(state, nodeId); + }); return state; } @@ -183,8 +208,10 @@ export function rootReducer(state = initialState, action) { const prevSelectedNodeId = state.get('selectedNodeId'); const prevDetailsStackSize = state.get('nodeDetails').size; + if (shouldCloseExisting(state)) { // click on sibling closes all - state = closeAllNodeDetails(state); + state = closeAllNodeDetails(state); + } // select new node if it's not the same (in that case just delesect) if (prevDetailsStackSize > 1 || prevSelectedNodeId !== action.nodeId) { @@ -265,6 +292,47 @@ export function rootReducer(state = initialState, action) { return state; } + // + // networks + // + + case ActionTypes.SHOW_NETWORKS: { + return state.set('showingNetworks', action.visible); + } + + case ActionTypes.SELECT_NETWORK: { + return state.set('selectedNetwork', action.networkId); + } + + case ActionTypes.PIN_NETWORK: { + state = closeAllNodeDetails(state); + + state = state.setIn(['nodeDetails', action.networkId], + { + id: action.networkId, + label: action.networkId, + origin: null, + topologyId: 'networks' + } + ); + + return state.merge({ + pinnedNetwork: action.networkId, + selectedNetwork: action.networkId + }); + } + + case ActionTypes.UNPIN_NETWORK: { + state = closeNodeDetails(state, action.networkId); + return state.merge({ + pinnedNetwork: null, + }); + } + + // + // metrics + // + case ActionTypes.SELECT_METRIC: { return state.set('selectedMetric', action.metricId); } @@ -481,6 +549,15 @@ export function rootReducer(state = initialState, action) { // apply pinned searches, filters nodes that dont match state = applyPinnedSearches(state); + state = state.set('availableNetworks', state.get('nodes') + .valueSeq() + .flatMap(node => (node.get('networks') || makeList()).map(n => ( + makeMap({id: n, label: n}) + ))) + .toSet() + .toList() + .sort()); + state = state.set('availableCanvasMetrics', state.get('nodes') .valueSeq() .flatMap(n => (n.get('metrics') || makeList()).map(m => ( @@ -579,6 +656,16 @@ export function rootReducer(state = initialState, action) { if (!isDeepEqual(state.get('nodeDetails').keySeq(), actionNodeDetails.keySeq())) { state = state.set('nodeDetails', actionNodeDetails); } + // + // load up network view state + // TODO: cleanup/extract. + // + const networkNodes = action.state.nodeDetails.filter(n => n.topologyId === 'networks'); + if (networkNodes.length > 0) { + state = state.set('pinnedNetwork', networkNodes[0].id); + state = state.set('selectedNetwork', networkNodes[0].id); + state = state.set('showingNetworks', true); + } } else { state = state.update('nodeDetails', nodeDetails => nodeDetails.clear()); } diff --git a/client/app/scripts/utils/color-utils.js b/client/app/scripts/utils/color-utils.js index 7d997d405..27247b5e3 100644 --- a/client/app/scripts/utils/color-utils.js +++ b/client/app/scripts/utils/color-utils.js @@ -12,7 +12,7 @@ const letterRange = endLetterRange - startLetterRange; /** * Converts a text to a 360 degree value */ -function text2degree(text) { +export function text2degree(text) { const input = text.substr(0, 2).toUpperCase(); let num = 0; for (let i = 0; i < input.length; i++) { diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 1ead30216..7fbe30e8b 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -341,6 +341,11 @@ h2 { transition: opacity .5s @base-ease; text-align: center; + .node-networks-overlay { + fill: none; + stroke: @background-darker-secondary-color; + } + .node-label, .node-sublabel { line-height: 125%;