diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 792d9b821..5704ccdd8 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -32,6 +32,57 @@ export function toggleHelp() { }; } +// +// Networks +// + + +export function showNetworks(visible) { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.SHOW_NETWORKS, + visible + }); + + updateRoute(getState); + }; +} + + +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..91f387caa --- /dev/null +++ b/client/app/scripts/charts/node-networks-overlay.js @@ -0,0 +1,58 @@ +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 barHeight = 5; +const barMarginTop = 6; +const labelHeight = 32; +// Gap size between bar segments. +const padding = 0.05; +const rx = 1; +const ry = rx; +const x = d3.scale.ordinal(); + +function NodeNetworksOverlay({labelOffsetY, size, stack, networks = makeList()}) { + const offset = labelOffsetY + labelHeight + barMarginTop; + + // Min size is about a quarter of the width, feels about right. + const minBarWidth = (size / 4); + const barWidth = Math.max(size, minBarWidth * networks.size); + + // Update singleton scale. + x.domain(networks.map((n, i) => i).toJS()); + x.rangeBands([barWidth * -0.5, barWidth * 0.5], padding, 0); + + const bars = networks.map((n, i) => ( + + )); + + let transform = ''; + if (stack) { + const contrastMode = isContrastMode(); + const [dx, dy] = contrastMode ? [0, 8] : [0, 0]; + transform = `translate(${dx}, ${dy * -1.5})`; + } + + return ( + + {bars.toJS()} + + ); +} + +export default NodeNetworksOverlay; diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index f28f52719..2ff1b47c1 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; @@ -97,10 +99,11 @@ class Node extends React.Component { const labelClassName = classnames('node-label', { truncate }); const subLabelClassName = classnames('node-sublabel', { truncate }); + const matchedResultsStyle = showingNetworks ? { marginTop: 6 } : null; const NodeShapeType = getNodeShape(this.props); const useSvgLabels = exportingGraph; - + const size = nodeScale(scaleFactor); return ( @@ -118,16 +121,20 @@ class Node extends React.Component {
- {!blurred && } + {!blurred && } } + + {showingNetworks && }
); } @@ -152,7 +159,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-edges.js b/client/app/scripts/charts/nodes-chart-edges.js index 29d26ffe0..fd764b819 100644 --- a/client/app/scripts/charts/nodes-chart-edges.js +++ b/client/app/scripts/charts/nodes-chart-edges.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Map as makeMap } from 'immutable'; +import { Map as makeMap, List as makeList } from 'immutable'; import { hasSelectedNode as hasSelectedNodeFn } from '../utils/topology-utils'; import EdgeContainer from './edge-container'; @@ -9,7 +9,7 @@ class NodesChartEdges extends React.Component { render() { const { hasSelectedNode, highlightedEdgeIds, layoutEdges, layoutPrecision, searchNodeMatches = makeMap(), searchQuery, - selectedNodeId } = this.props; + selectedNodeId, selectedNetwork, selectedNetworkNodes } = this.props; return ( @@ -18,10 +18,16 @@ class NodesChartEdges extends React.Component { const targetSelected = selectedNodeId === edge.get('target'); const highlighted = highlightedEdgeIds.has(edge.get('id')); const focused = hasSelectedNode && (sourceSelected || targetSelected); - const blurred = !(highlightedEdgeIds.size > 0 && highlighted) - && ((hasSelectedNode && !sourceSelected && !targetSelected) - || !focused && searchQuery && !(searchNodeMatches.has(edge.get('source')) - && searchNodeMatches.has(edge.get('target')))); + const otherNodesSelected = hasSelectedNode && !sourceSelected && !targetSelected; + const noMatches = searchQuery && + !(searchNodeMatches.has(edge.get('source')) && + searchNodeMatches.has(edge.get('target'))); + const noSelectedNetworks = selectedNetwork && + !(selectedNetworkNodes.contains(edge.get('source')) && + selectedNetworkNodes.contains(edge.get('target'))); + const blurred = !highlighted && (otherNodesSelected || + !focused && noMatches || + !focused && noSelectedNetworks); return ( node.set('blurred', selectedNodeId && !node.get('focused') || searchQuery && !searchNodeMatches.has(node.get('id')) - && !node.get('highlighted')); + && !node.get('highlighted') + || selectedNetwork + && !(node.get('networks') || makeList()).find(n => n.get('id') === selectedNetwork)); // make sure blurred nodes are in the background const sortNodes = node => { @@ -63,6 +65,7 @@ class NodesChartNodes extends React.Component { matches={searchNodeMatches.get(node.get('id'))} highlighted={node.get('highlighted')} shape={node.get('shape')} + networks={node.get('networks')} stack={node.get('stack')} key={node.get('id')} id={node.get('id')} @@ -90,6 +93,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 3e957a4f5..29bb8eb94 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -178,7 +178,8 @@ class NodesChart extends React.Component { metrics: node.get('metrics'), rank: node.get('rank'), shape: node.get('shape'), - stack: node.get('stack') + stack: node.get('stack'), + networks: node.get('networks'), })); }); diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index f700dd477..3da6dcb66 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -13,7 +13,7 @@ const DEFAULT_MARGINS = {top: 0, left: 0}; const DEFAULT_SCALE = val => val * 2; const NODE_SIZE_FACTOR = 1; const NODE_SEPARATION_FACTOR = 3.0; -const RANK_SEPARATION_FACTOR = 2.5; +const RANK_SEPARATION_FACTOR = 3.0; let layoutRuns = 0; let layoutRunsTrivial = 0; 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 214f1a1e3..97145ff7c 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,16 @@ 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', 'zb', 'db', 're', 'gh', 'jk', 'lol', 'nw' +].map(n => ({id: n, label: n, colorKey: n})); 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 +27,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 +45,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 = NETWORKS +) => ({ adjacency, controls: {}, shape, @@ -51,9 +58,9 @@ const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCou label: name, label_minor: name, latest: {}, - metadata: {}, origins: [], - rank: name + rank: name, + networks }); @@ -170,7 +177,8 @@ class DebugToolbar extends React.Component { sample(allNodes), _.sample(SHAPES), _.sample(STACK_VARIANTS), - _.sample(NODE_COUNTS) + _.sample(NODE_COUNTS), + sample(NETWORKS, 10) )) })); @@ -208,8 +216,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/matched-results.js b/client/app/scripts/components/matched-results.js index e044b69f5..5f97384d7 100644 --- a/client/app/scripts/components/matched-results.js +++ b/client/app/scripts/components/matched-results.js @@ -25,7 +25,7 @@ class MatchedResults extends React.Component { } render() { - const { matches } = this.props; + const { matches, style } = this.props; if (!matches) { return null; @@ -42,7 +42,7 @@ class MatchedResults extends React.Component { } return ( -
+
{matches.keySeq().take(SHOW_ROW_COUNT).map(fieldId => this.renderMatch(matches, fieldId))} {moreFieldMatches &&
{`${moreFieldMatches.size} more matches`} 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..fe69fd676 --- /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('network-selector-action', { + 'network-selector-action-selected': isSelected + }); + const style = { + borderBottomColor: getNodeColor(network.get('colorKey', 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..2a8d55525 --- /dev/null +++ b/client/app/scripts/components/networks-selector.js @@ -0,0 +1,63 @@ +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('network-selector-action', { + 'network-selector-action-selected': showingNetworks + }); + + const style = { + borderBottomColor: showingNetworks ? '#A2A0B3' : 'transparent' + }; + + 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/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js index cfde7428e..0121da97d 100644 --- a/client/app/scripts/reducers/__tests__/root-test.js +++ b/client/app/scripts/reducers/__tests__/root-test.js @@ -2,6 +2,7 @@ jest.dontMock('../../utils/router-utils'); jest.dontMock('../../utils/search-utils'); jest.dontMock('../../utils/string-utils'); jest.dontMock('../../utils/topology-utils'); +jest.dontMock('../../utils/network-view-utils'); jest.dontMock('../../constants/action-types'); jest.dontMock('../root'); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 244f09921..968400a01 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -6,6 +6,8 @@ import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap, import ActionTypes from '../constants/action-types'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; import { applyPinnedSearches, updateNodeMatches } from '../utils/search-utils'; +import { getNetworkNodes, getAvailableNetworks } from '../utils/network-view-utils'; +import { longestCommonPrefix } from '../utils/string-utils'; import { findTopologyById, getAdjacentNodes, setTopologyUrlsById, updateTopologyIds, filterHiddenTopologies } from '../utils/topology-utils'; @@ -20,6 +22,7 @@ const topologySorter = topology => topology.get('rank'); export const initialState = makeMap({ availableCanvasMetrics: makeList(), + availableNetworks: makeList(), controlPipes: makeOrderedMap(), // pipeId -> controlPipe controlStatus: makeMap(), currentTopology: null, @@ -31,6 +34,7 @@ export const initialState = makeMap({ hostname: '...', mouseOverEdgeId: null, mouseOverNodeId: null, + networkNodes: makeMap(), nodeDetails: makeOrderedMap(), // nodeId -> details nodes: makeOrderedMap(), // nodeId -> node // nodes cache, infrequently updated, used for search @@ -39,6 +43,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 +51,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 @@ -265,6 +272,39 @@ export function rootReducer(state = initialState, action) { return state; } + // + // networks + // + + case ActionTypes.SHOW_NETWORKS: { + if (!action.visible) { + state = state.set('selectedNetwork', null); + state = state.set('pinnedNetwork', null); + } + return state.set('showingNetworks', action.visible); + } + + case ActionTypes.SELECT_NETWORK: { + return state.set('selectedNetwork', action.networkId); + } + + case ActionTypes.PIN_NETWORK: { + return state.merge({ + pinnedNetwork: action.networkId, + selectedNetwork: action.networkId + }); + } + + case ActionTypes.UNPIN_NETWORK: { + return state.merge({ + pinnedNetwork: null, + }); + } + + // + // metrics + // + case ActionTypes.SELECT_METRIC: { return state.set('selectedMetric', action.metricId); } @@ -481,6 +521,38 @@ export function rootReducer(state = initialState, action) { // apply pinned searches, filters nodes that dont match state = applyPinnedSearches(state); + // TODO move this setting of networks as toplevel node field to backend, + // to not rely on field IDs here. should be determined by topology implementer + state = state.update('nodes', nodes => nodes.map(node => { + if (node.has('metadata')) { + const networks = node.get('metadata') + .find(field => field.get('id') === 'docker_container_networks'); + if (networks) { + return node.set('networks', fromJS( + networks.get('value').split(', ').map(n => ({id: n, label: n, colorKey: n})))); + } + } + return node; + })); + + state = state.set('networkNodes', getNetworkNodes(state.get('nodes'))); + state = state.set('availableNetworks', getAvailableNetworks(state.get('nodes'))); + + // optimize color coding for networks + const networkPrefix = longestCommonPrefix(state.get('availableNetworks') + .map(n => n.get('id')).toJS()); + + if (networkPrefix) { + state = state.update('nodes', + nodes => nodes.map(node => node.update('networks', + networks => networks.map(n => n.set('colorKey', + n.get('colorKey').substr(networkPrefix.length)))))); + + state = state.update('availableNetworks', + networks => networks.map(network => network + .set('colorKey', network.get('id').substr(networkPrefix.length)))); + } + state = state.set('availableCanvasMetrics', state.get('nodes') .valueSeq() .flatMap(n => (n.get('metrics') || makeList()).map(m => ( @@ -564,6 +636,13 @@ export function rootReducer(state = initialState, action) { selectedNodeId: action.state.selectedNodeId, pinnedMetricType: action.state.pinnedMetricType }); + if (action.state.showingNetworks) { + state = state.set('showingNetworks', action.state.showingNetworks); + } + if (action.state.pinnedNetwork) { + state = state.set('pinnedNetwork', action.state.pinnedNetwork); + state = state.set('selectedNetwork', action.state.pinnedNetwork); + } if (action.state.controlPipe) { state = state.set('controlPipes', makeOrderedMap({ [action.state.controlPipe.id]: diff --git a/client/app/scripts/utils/__tests__/string-utils-test.js b/client/app/scripts/utils/__tests__/string-utils-test.js index 2426d6a35..a6ffb501e 100644 --- a/client/app/scripts/utils/__tests__/string-utils-test.js +++ b/client/app/scripts/utils/__tests__/string-utils-test.js @@ -10,4 +10,15 @@ describe('StringUtils', () => { expect(formatMetric(0)).toBe('0.00'); }); }); + + describe('longestCommonPrefix', () => { + const fun = StringUtils.longestCommonPrefix; + + it('it should return the longest common prefix', () => { + expect(fun(['interspecies', 'interstellar'])).toBe('inters'); + expect(fun(['space', 'space'])).toBe('space'); + expect(fun([''])).toBe(''); + expect(fun(['prefix', 'suffix'])).toBe(''); + }); + }); }); 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/scripts/utils/network-view-utils.js b/client/app/scripts/utils/network-view-utils.js new file mode 100644 index 000000000..8565fc8e6 --- /dev/null +++ b/client/app/scripts/utils/network-view-utils.js @@ -0,0 +1,20 @@ +import { fromJS, List as makeList } from 'immutable'; + +export function getNetworkNodes(nodes) { + const networks = {}; + nodes.forEach(node => (node.get('networks') || makeList()).forEach(n => { + const networkId = n.get('id'); + networks[networkId] = (networks[networkId] || []).concat([node.get('id')]); + })); + return fromJS(networks); +} + + +export function getAvailableNetworks(nodes) { + return nodes + .valueSeq() + .flatMap(node => node.get('networks') || makeList()) + .toSet() + .toList() + .sortBy(m => m.get('label')); +} diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index 9e92a0bf9..0aba5a157 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -37,7 +37,7 @@ export function getUrlState(state) { id: details.id, label: details.label, topologyId: details.topologyId })); - return { + const urlState = { controlPipe: cp ? cp.toJS() : null, nodeDetails: nodeDetails.toJS(), pinnedMetricType: state.get('pinnedMetricType'), @@ -47,6 +47,15 @@ export function getUrlState(state) { topologyId: state.get('currentTopologyId'), topologyOptions: state.get('topologyOptions').toJS() // all options }; + + if (state.get('showingNetworks')) { + urlState.showingNetworks = true; + if (state.get('pinnedNetwork')) { + urlState.pinnedNetwork = state.get('pinnedNetwork'); + } + } + + return urlState; } export function updateRoute(getState) { diff --git a/client/app/scripts/utils/string-utils.js b/client/app/scripts/utils/string-utils.js index 5f8d180d5..788431693 100644 --- a/client/app/scripts/utils/string-utils.js +++ b/client/app/scripts/utils/string-utils.js @@ -1,7 +1,7 @@ import React from 'react'; import filesize from 'filesize'; import d3 from 'd3'; - +import LCP from 'lcp'; const formatLargeValue = d3.format('s'); @@ -69,3 +69,7 @@ const CLEAN_LABEL_REGEX = /\W/g; export function slugify(label) { return label.replace(CLEAN_LABEL_REGEX, '').toLowerCase(); } + +export function longestCommonPrefix(strArr) { + return (new LCP(strArr)).lcp(); +} diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 1ead30216..2a23d40ff 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-network { + // stroke: @background-lighter-color; + // stroke-width: 4px; + } + .node-label, .node-sublabel { line-height: 125%; @@ -1116,7 +1121,7 @@ h2 { } } -.topology-option, .metric-selector { +.topology-option, .metric-selector, .network-selector { color: @text-secondary-color; margin: 6px 0; @@ -1140,6 +1145,7 @@ h2 { padding: 3px 12px; cursor: pointer; display: inline-block; + background-color: @background-color; &-selected, &:hover { color: @text-darker-color; @@ -1167,6 +1173,11 @@ h2 { } } +.network-selector-action { + border-top: 3px solid transparent; + border-bottom: 3px solid @background-dark-color; +} + .warning { display: inline-block; cursor: pointer; diff --git a/client/package.json b/client/package.json index f09870513..f2d2a888e 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,7 @@ "font-awesome": "4.5.0", "font-awesome-webpack": "0.0.4", "immutable": "~3.7.4", + "lcp": "1.0.0", "lodash": "~4.6.1", "materialize-css": "0.97.5", "moment": "2.12.0", diff --git a/probe/docker/container.go b/probe/docker/container.go index 24727b46c..a559ba4b9 100644 --- a/probe/docker/container.go +++ b/probe/docker/container.go @@ -27,6 +27,7 @@ const ( ContainerCommand = "docker_container_command" ContainerPorts = "docker_container_ports" ContainerCreated = "docker_container_created" + ContainerNetworks = "docker_container_networks" ContainerIPs = "docker_container_ips" ContainerHostname = "docker_container_hostname" ContainerIPsWithScopes = "docker_container_ips_with_scopes" @@ -309,17 +310,29 @@ func addScopeToIPs(hostID string, ips []string) []string { func (c *container) NetworkInfo(localAddrs []net.IP) report.Sets { c.RLock() defer c.RUnlock() + + // For now, for the proof-of-concept, we just add networks as a set of + // names. For the next iteration, we will probably want to create a new + // Network topology, populate the network nodes with all of the details + // here, and provide foreign key links from nodes to networks. + networks := make([]string, 0, len(c.container.NetworkSettings.Networks)) + for name := range c.container.NetworkSettings.Networks { + networks = append(networks, name) + } + ips := c.container.NetworkSettings.SecondaryIPAddresses if c.container.NetworkSettings.IPAddress != "" { ips = append(ips, c.container.NetworkSettings.IPAddress) } + // Treat all Docker IPs as local scoped. ipsWithScopes := addScopeToIPs(c.hostID, ips) + return report.EmptySets. + Add(ContainerNetworks, report.MakeStringSet(networks...)). Add(ContainerPorts, c.ports(localAddrs)). Add(ContainerIPs, report.MakeStringSet(ips...)). Add(ContainerIPsWithScopes, report.MakeStringSet(ipsWithScopes...)) - } func (c *container) memoryUsageMetric(stats []docker.Stats) report.Metric { diff --git a/probe/docker/container_test.go b/probe/docker/container_test.go index c77e92007..0c8faad37 100644 --- a/probe/docker/container_test.go +++ b/probe/docker/container_test.go @@ -112,6 +112,7 @@ func TestContainer(t *testing.T) { { want := report.EmptySets. Add("docker_container_ports", report.MakeStringSet("1.2.3.4:80->80/tcp", "81/tcp")). + Add("docker_container_networks", nil). Add("docker_container_ips", report.MakeStringSet("1.2.3.4")). Add("docker_container_ips_with_scopes", report.MakeStringSet(";1.2.3.4")) diff --git a/probe/docker/registry.go b/probe/docker/registry.go index 8014a87ab..e633dd255 100644 --- a/probe/docker/registry.go +++ b/probe/docker/registry.go @@ -16,14 +16,16 @@ import ( // Consts exported for testing. const ( - CreateEvent = "create" - DestroyEvent = "destroy" - RenameEvent = "rename" - StartEvent = "start" - DieEvent = "die" - PauseEvent = "pause" - UnpauseEvent = "unpause" - endpoint = "unix:///var/run/docker.sock" + CreateEvent = "create" + DestroyEvent = "destroy" + RenameEvent = "rename" + StartEvent = "start" + DieEvent = "die" + PauseEvent = "pause" + UnpauseEvent = "unpause" + NetworkConnectEvent = "network:connect" + NetworkDisconnectEvent = "network:disconnect" + endpoint = "unix:///var/run/docker.sock" ) // Vars exported for testing. @@ -249,7 +251,7 @@ func (r *registry) updateImages() error { func (r *registry) handleEvent(event *docker_client.APIEvents) { switch event.Status { - case CreateEvent, RenameEvent, StartEvent, DieEvent, DestroyEvent, PauseEvent, UnpauseEvent: + case CreateEvent, RenameEvent, StartEvent, DieEvent, DestroyEvent, PauseEvent, UnpauseEvent, NetworkConnectEvent, NetworkDisconnectEvent: r.updateContainerState(event.ID, stateAfterEvent(event.Status)) } } diff --git a/probe/docker/reporter.go b/probe/docker/reporter.go index 640689707..8fdbedfaf 100644 --- a/probe/docker/reporter.go +++ b/probe/docker/reporter.go @@ -26,9 +26,10 @@ var ( ImageID: {ID: ImageID, Label: "Image ID", From: report.FromLatest, Truncate: 12, Priority: 11}, ContainerUptime: {ID: ContainerUptime, Label: "Uptime", From: report.FromLatest, Priority: 12}, ContainerRestartCount: {ID: ContainerRestartCount, Label: "Restart #", From: report.FromLatest, Priority: 13}, - ContainerIPs: {ID: ContainerIPs, Label: "IPs", From: report.FromSets, Priority: 14}, - ContainerPorts: {ID: ContainerPorts, Label: "Ports", From: report.FromSets, Priority: 15}, - ContainerCreated: {ID: ContainerCreated, Label: "Created", From: report.FromLatest, Priority: 16}, + ContainerNetworks: {ID: ContainerNetworks, Label: "Networks", From: report.FromSets, Priority: 14}, + ContainerIPs: {ID: ContainerIPs, Label: "IPs", From: report.FromSets, Priority: 15}, + ContainerPorts: {ID: ContainerPorts, Label: "Ports", From: report.FromSets, Priority: 16}, + ContainerCreated: {ID: ContainerCreated, Label: "Created", From: report.FromLatest, Priority: 17}, } ContainerMetricTemplates = report.MetricTemplates{ diff --git a/render/detailed/metadata_test.go b/render/detailed/metadata_test.go index 21830cdd0..b2b98262a 100644 --- a/render/detailed/metadata_test.go +++ b/render/detailed/metadata_test.go @@ -29,7 +29,7 @@ func TestNodeMetadata(t *testing.T) { want: []report.MetadataRow{ {ID: docker.ContainerID, Label: "ID", Value: fixture.ClientContainerID, Priority: 1}, {ID: docker.ContainerStateHuman, Label: "State", Value: "running", Priority: 2}, - {ID: docker.ContainerIPs, Label: "IPs", Value: "10.10.10.0/24, 10.10.10.1/24", Priority: 14}, + {ID: docker.ContainerIPs, Label: "IPs", Value: "10.10.10.0/24, 10.10.10.1/24", Priority: 15}, }, }, {