Merge pull request #1528 from weaveworks/network-view

[WIP] Network view
This commit is contained in:
Simon
2016-06-06 17:03:15 +02:00
27 changed files with 493 additions and 48 deletions
+51
View File
@@ -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,
@@ -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) => (
<rect
x={x(i)}
y={offset}
width={x.rangeBand()}
height={barHeight}
rx={rx}
ry={ry}
className="node-network"
style={{
fill: getNodeColor(n.get('colorKey'))
}}
key={n.get('id')}
/>
));
let transform = '';
if (stack) {
const contrastMode = isContrastMode();
const [dx, dy] = contrastMode ? [0, 8] : [0, 0];
transform = `translate(${dx}, ${dy * -1.5})`;
}
return (
<g transform={transform}>
{bars.toJS()}
</g>
);
}
export default NodeNetworksOverlay;
+14 -6
View File
@@ -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 (
<g className={nodeClassName} transform={transform}
onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
@@ -118,16 +121,20 @@ class Node extends React.Component {
<div className={subLabelClassName}>
<MatchedText text={subLabel} match={matches.get('sublabel')} />
</div>
{!blurred && <MatchedResults matches={matches.get('metadata')} />}
{!blurred && <MatchedResults matches={matches.get('metadata')}
style={matchedResultsStyle} />}
</div>
</foreignObject>}
<g onClick={this.handleMouseClick}>
<NodeShapeType
size={nodeScale(scaleFactor)}
size={size}
color={color}
{...this.props} />
</g>
{showingNetworks && <NodeNetworksOverlay labelOffsetY={labelOffsetY}
size={size} networks={networks} stack={stack} />}
</g>
);
}
@@ -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);
+15 -7
View File
@@ -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 (
<g className="nodes-chart-edges">
@@ -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 (
<EdgeContainer
@@ -49,7 +55,9 @@ function mapStateToProps(state) {
highlightedEdgeIds: state.get('highlightedEdgeIds'),
searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]),
searchQuery: state.get('searchQuery'),
selectedNodeId: state.get('selectedNodeId')
selectedNetwork: state.get('selectedNetwork'),
selectedNetworkNodes: state.getIn(['networkNodes', state.get('selectedNetwork')], makeList()),
selectedNodeId: state.get('selectedNodeId'),
};
}
@@ -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,9 @@ 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()).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'),
+2 -1
View File
@@ -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'),
}));
});
+1 -1
View File
@@ -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;
+5 -1
View File
@@ -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 (
<div className="app">
@@ -124,6 +126,7 @@ class App extends React.Component {
<Sidebar>
<Status />
{showingMetricsSelector && <MetricSelector />}
{showingNetworkSelector && <NetworkSelector />}
<TopologyOptions />
</Sidebar>
@@ -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)
};
+29 -8
View File
@@ -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 {
<button onClick={this.toggleColors}>toggle</button>
</div>
{this.state.showColors && [getNodeColor, getNodeColorDark].map(fn => (
<table>
{this.state.showColors &&
<table>
<tbody>
{LABEL_PREFIXES.map(r => (
<tr key={r}>
<td
title={`${r}`}
style={{backgroundColor: d3.hsl(text2degree(r), 0.5, 0.5).toString()}} />
</tr>
))}
</tbody>
</table>}
{this.state.showColors && [getNodeColor, getNodeColorDark].map((fn, i) => (
<table key={i}>
<tbody>
{LABEL_PREFIXES.map(r => (
<tr key={r}>
@@ -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 (
<div className="matched-results">
<div className="matched-results" style={style}>
{matches.keySeq().take(SHOW_ROW_COUNT).map(fieldId => this.renderMatch(matches, fieldId))}
{moreFieldMatches && <div className="matched-results-more" title={moreFieldMatchesTitle}>
{`${moreFieldMatches.size} more matches`}
@@ -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 (
<div
key={id}
className={className}
onMouseOver={this.onMouseOver}
onClick={this.onMouseClick}
style={style}>
{network.get('label')}
{isPinned && <span className="fa fa-thumb-tack"></span>}
</div>
);
}
}
function mapStateToProps(state) {
return {
selectedNetwork: state.get('selectedNetwork'),
pinnedNetwork: state.get('pinnedNetwork')
};
}
export default connect(
mapStateToProps,
{ selectNetwork, pinNetwork, unpinNetwork }
)(NetworkSelectorItem);
@@ -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 => (
<NetworkSelectorItem key={network.get('id')} network={network} />
));
const className = classNames('network-selector-action', {
'network-selector-action-selected': showingNetworks
});
const style = {
borderBottomColor: showingNetworks ? '#A2A0B3' : 'transparent'
};
return (
<div className="network-selector">
<div className="network-selector-wrapper" onMouseLeave={this.onMouseOut}>
<div className={className} onClick={this.onClick} style={style}>
Networks
</div>
{showingNetworks && items}
</div>
</div>
);
}
}
function mapStateToProps(state) {
return {
availableNetworks: state.get('availableNetworks'),
showingNetworks: state.get('showingNetworks'),
pinnedNetwork: state.get('pinnedNetwork')
};
}
export default connect(
mapStateToProps,
{ selectNetwork, showNetworks }
)(NetworkSelector);
+6 -1
View File
@@ -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);
@@ -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');
+79
View File
@@ -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]:
@@ -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('');
});
});
});
+1 -1
View File
@@ -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++) {
@@ -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'));
}
+10 -1
View File
@@ -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) {
+5 -1
View File
@@ -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();
}
+12 -1
View File
@@ -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;
+1
View File
@@ -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",
+14 -1
View File
@@ -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 {
+1
View File
@@ -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"))
+11 -9
View File
@@ -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))
}
}
+4 -3
View File
@@ -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{
+1 -1
View File
@@ -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},
},
},
{