{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},
},
},
{