diff --git a/app/api_topologies.go b/app/api_topologies.go
index 5f82aa674e..f62511e3f6 100644
--- a/app/api_topologies.go
+++ b/app/api_topologies.go
@@ -9,10 +9,18 @@ import (
// APITopologyDesc is returned in a list by the /api/topology handler.
type APITopologyDesc struct {
- Name string `json:"name"`
- URL string `json:"url"`
- SubTopologies []APITopologyDesc `json:"sub_topologies,omitempty"`
- Stats *topologyStats `json:"stats,omitempty"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ SubTopologies []APITopologyDesc `json:"sub_topologies,omitempty"`
+ Options map[string][]APITopologyOption `json:"options"`
+ Stats *topologyStats `json:"stats,omitempty"`
+}
+
+// APITopologyOption describes a ¶m=value to a given topology.
+type APITopologyOption struct {
+ Value string `json:"value"`
+ Display string `json:"display"`
+ Default bool `json:"default,omitempty"`
}
type topologyStats struct {
@@ -29,23 +37,30 @@ func makeTopologyList(rep xfer.Reporter) func(w http.ResponseWriter, r *http.Req
topologies = []APITopologyDesc{}
)
for name, def := range topologyRegistry {
+ // Don't show sub-topologies at the top level.
if def.parent != "" {
- continue // subtopology, don't show at top level
+ continue
}
+
+ // Collect all sub-topologies of this one, depth=1 only.
subTopologies := []APITopologyDesc{}
for subName, subDef := range topologyRegistry {
if subDef.parent == name {
subTopologies = append(subTopologies, APITopologyDesc{
- Name: subDef.human,
- URL: "/api/topology/" + subName,
- Stats: stats(subDef.renderer.Render(rpt)),
+ Name: subDef.human,
+ URL: "/api/topology/" + subName,
+ Options: makeTopologyOptions(subDef),
+ Stats: stats(subDef.renderer.Render(rpt)),
})
}
}
+
+ // Append.
topologies = append(topologies, APITopologyDesc{
Name: def.human,
URL: "/api/topology/" + name,
SubTopologies: subTopologies,
+ Options: makeTopologyOptions(def),
Stats: stats(def.renderer.Render(rpt)),
})
}
@@ -53,6 +68,20 @@ func makeTopologyList(rep xfer.Reporter) func(w http.ResponseWriter, r *http.Req
}
}
+func makeTopologyOptions(view topologyView) map[string][]APITopologyOption {
+ options := map[string][]APITopologyOption{}
+ for param, optionVals := range view.options {
+ for _, optionVal := range optionVals {
+ options[param] = append(options[param], APITopologyOption{
+ Value: optionVal.value,
+ Display: optionVal.human,
+ Default: optionVal.def,
+ })
+ }
+ }
+ return options
+}
+
func stats(r render.RenderableNodes) *topologyStats {
var (
nodes int
diff --git a/app/router.go b/app/router.go
index 88225dbc4a..3285f0c1a2 100644
--- a/app/router.go
+++ b/app/router.go
@@ -110,6 +110,14 @@ func captureTopology(rep xfer.Reporter, f func(xfer.Reporter, topologyView, http
http.NotFound(w, r)
return
}
+ for param, opts := range topology.options {
+ value := r.FormValue(param)
+ for _, opt := range opts {
+ if (value == "" && opt.def) || (opt.value != "" && opt.value == value) {
+ topology.renderer = opt.decorator(topology.renderer)
+ }
+ }
+ }
f(rep, topology, w, r)
}
}
@@ -138,11 +146,19 @@ var topologyRegistry = map[string]topologyView{
human: "Containers",
parent: "",
renderer: render.ContainerWithImageNameRenderer{},
+ options: optionParams{"system": {
+ {"show", "Show system containers", false, nop},
+ {"hide", "Hide system containers", true, render.FilterSystem},
+ }},
},
"containers-by-image": {
human: "by image",
parent: "containers",
renderer: render.ContainerImageRenderer,
+ options: optionParams{"system": {
+ {"show", "Show system containers", false, nop},
+ {"hide", "Hide system containers", true, render.FilterSystem},
+ }},
},
"hosts": {
human: "Hosts",
@@ -155,4 +171,16 @@ type topologyView struct {
human string
parent string
renderer render.Renderer
+ options optionParams
+}
+
+type optionParams map[string][]optionValue // param: values
+
+type optionValue struct {
+ value string // "hide"
+ human string // "Hide system containers"
+ def bool
+ decorator func(render.Renderer) render.Renderer
}
+
+func nop(r render.Renderer) render.Renderer { return r }
diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js
index 943f6453fb..0373c2823f 100644
--- a/client/app/scripts/actions/app-actions.js
+++ b/client/app/scripts/actions/app-actions.js
@@ -5,6 +5,16 @@ let RouterUtils;
let WebapiUtils;
module.exports = {
+ changeTopologyOption: function(option, value) {
+ AppDispatcher.dispatch({
+ type: ActionTypes.CHANGE_TOPOLOGY_OPTION,
+ option: option,
+ value: value
+ });
+ RouterUtils.updateRoute();
+ WebapiUtils.getNodesDelta(AppStore.getCurrentTopologyUrl(), AppStore.getActiveTopologyOptions());
+ },
+
clickCloseDetails: function() {
AppDispatcher.dispatch({
type: ActionTypes.CLICK_CLOSE_DETAILS
@@ -27,7 +37,7 @@ module.exports = {
topologyId: topologyId
});
RouterUtils.updateRoute();
- WebapiUtils.getNodesDelta(AppStore.getCurrentTopologyUrl());
+ WebapiUtils.getNodesDelta(AppStore.getCurrentTopologyUrl(), AppStore.getActiveTopologyOptions());
},
openWebsocket: function() {
@@ -96,7 +106,7 @@ module.exports = {
type: ActionTypes.RECEIVE_TOPOLOGIES,
topologies: topologies
});
- WebapiUtils.getNodesDelta(AppStore.getCurrentTopologyUrl());
+ WebapiUtils.getNodesDelta(AppStore.getCurrentTopologyUrl(), AppStore.getActiveTopologyOptions());
WebapiUtils.getNodeDetails(AppStore.getCurrentTopologyUrl(), AppStore.getSelectedNodeId());
},
@@ -119,7 +129,7 @@ module.exports = {
state: state,
type: ActionTypes.ROUTE_TOPOLOGY
});
- WebapiUtils.getNodesDelta(AppStore.getCurrentTopologyUrl());
+ WebapiUtils.getNodesDelta(AppStore.getCurrentTopologyUrl(), AppStore.getActiveTopologyOptions());
WebapiUtils.getNodeDetails(AppStore.getCurrentTopologyUrl(), AppStore.getSelectedNodeId());
}
};
diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js
index 505a2a7d39..2a6d8b6741 100644
--- a/client/app/scripts/charts/nodes-chart.js
+++ b/client/app/scripts/charts/nodes-chart.js
@@ -211,8 +211,12 @@ const NodesChart = React.createClass({
debug('graph layout took ' + timedLayouter.time + 'ms');
- // adjust layout based on viewport
+ // layout was aborted
+ if (!graph) {
+ return;
+ }
+ // adjust layout based on viewport
const xFactor = (props.width - MARGINS.left - MARGINS.right) / graph.width;
const yFactor = props.height / graph.height;
const zoomFactor = Math.min(xFactor, yFactor);
diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js
index 2902e8a421..39d5120f1c 100644
--- a/client/app/scripts/components/app.js
+++ b/client/app/scripts/components/app.js
@@ -4,6 +4,7 @@ const Logo = require('./logo');
const AppStore = require('../stores/app-store');
const Status = require('./status.js');
const Topologies = require('./topologies.js');
+const TopologyOptions = require('./topology-options.js');
const WebapiUtils = require('../utils/web-api-utils');
const AppActions = require('../actions/app-actions');
const Details = require('./details');
@@ -14,8 +15,10 @@ const ESC_KEY_CODE = 27;
function getStateFromStores() {
return {
+ activeTopologyOptions: AppStore.getActiveTopologyOptions(),
currentTopology: AppStore.getCurrentTopology(),
currentTopologyId: AppStore.getCurrentTopologyId(),
+ currentTopologyOptions: AppStore.getCurrentTopologyOptions(),
errorUrl: AppStore.getErrorUrl(),
highlightedEdgeIds: AppStore.getHighlightedEdgeIds(),
highlightedNodeIds: AppStore.getHighlightedNodeIds(),
@@ -67,6 +70,8 @@ const App = React.createClass({
+
diff --git a/client/app/scripts/components/topology-options.js b/client/app/scripts/components/topology-options.js
new file mode 100644
index 0000000000..38855a4863
--- /dev/null
+++ b/client/app/scripts/components/topology-options.js
@@ -0,0 +1,77 @@
+const React = require('react');
+const _ = require('lodash');
+const mui = require('material-ui');
+const DropDownMenu = mui.DropDownMenu;
+
+const AppActions = require('../actions/app-actions');
+
+const TopologyOptions = React.createClass({
+
+ componentDidMount: function() {
+ this.fixWidth();
+ },
+
+ onChange: function(ev, index, item) {
+ ev.preventDefault();
+ AppActions.changeTopologyOption(item.option, item.payload);
+ },
+
+ renderOption: function(items) {
+ let selected = 0;
+ let key;
+ const activeOptions = this.props.activeOptions;
+ const menuItems = items.map(function(item, index) {
+ if (activeOptions[item.option] && activeOptions[item.option] === item.value) {
+ selected = index;
+ }
+ key = item.option;
+ return {
+ option: item.option,
+ payload: item.value,
+ text: item.display
+ };
+ });
+
+ return (
+
+ );
+ },
+
+ render: function() {
+ const options = _.sortBy(
+ _.map(this.props.options, function(items, optionId) {
+ _.each(items, function(item) {
+ item.option = optionId;
+ });
+ items.option = optionId;
+ return items;
+ }),
+ 'option'
+ );
+
+ return (
+
+ {options.map(function(items) {
+ return this.renderOption(items);
+ }, this)}
+
+ );
+ },
+
+ componentDidUpdate: function() {
+ this.fixWidth();
+ },
+
+ fixWidth: function() {
+ const containerNode = this.refs.container.getDOMNode();
+ _.each(containerNode.childNodes, function(child) {
+ // set drop down width to length of current label
+ const label = child.getElementsByClassName('mui-menu-label')[0];
+ const width = label.getBoundingClientRect().width + 40;
+ child.style.width = width + 'px';
+ });
+ }
+});
+
+module.exports = TopologyOptions;
diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js
index 2450cbccdb..7d21c9cb35 100644
--- a/client/app/scripts/constants/action-types.js
+++ b/client/app/scripts/constants/action-types.js
@@ -1,6 +1,7 @@
const keymirror = require('keymirror');
module.exports = keymirror({
+ CHANGE_TOPOLOGY_OPTION: null,
CLICK_CLOSE_DETAILS: null,
CLICK_NODE: null,
CLICK_TOPOLOGY: null,
diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js
index f88dca4081..b7035cf785 100644
--- a/client/app/scripts/stores/__tests__/app-store-test.js
+++ b/client/app/scripts/stores/__tests__/app-store-test.js
@@ -28,6 +28,18 @@ describe('AppStore', function() {
// actions
+ const ChangeTopologyOptionAction = {
+ type: ActionTypes.CHANGE_TOPOLOGY_OPTION,
+ option: 'option1',
+ value: 'on'
+ };
+
+ const ChangeTopologyOptionAction2 = {
+ type: ActionTypes.CHANGE_TOPOLOGY_OPTION,
+ option: 'option1',
+ value: 'off'
+ };
+
const ClickNodeAction = {
type: ActionTypes.CLICK_NODE,
nodeId: 'n1'
@@ -88,6 +100,12 @@ describe('AppStore', function() {
topologies: [{
url: '/topo1',
name: 'Topo1',
+ options: {
+ option1: [
+ {value: 'on'},
+ {value: 'off', default: true}
+ ]
+ },
sub_topologies: [{
url: '/topo1-grouped',
name: 'topo 1 grouped'
@@ -122,6 +140,7 @@ describe('AppStore', function() {
expect(AppStore.getTopologies().length).toBe(1);
expect(AppStore.getCurrentTopology().name).toBe('Topo1');
expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1');
+ expect(AppStore.getCurrentTopologyOptions().option1).toBeDefined();
});
it('get sub-topology', function() {
@@ -131,6 +150,32 @@ describe('AppStore', function() {
expect(AppStore.getTopologies().length).toBe(1);
expect(AppStore.getCurrentTopology().name).toBe('topo 1 grouped');
expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1-grouped');
+ expect(AppStore.getCurrentTopologyOptions()).toBeUndefined();
+ });
+
+ // topology options
+
+ it('changes topology option', function() {
+ // default options
+ registeredCallback(ReceiveTopologiesAction);
+ registeredCallback(ClickTopologyAction);
+ expect(AppStore.getActiveTopologyOptions().option1).toBe('off');
+ expect(AppStore.getAppState().topologyOptions.option1).toBe('off');
+
+ // turn on
+ registeredCallback(ChangeTopologyOptionAction);
+ expect(AppStore.getActiveTopologyOptions().option1).toBe('on');
+ expect(AppStore.getAppState().topologyOptions.option1).toBe('on');
+
+ // turn off
+ registeredCallback(ChangeTopologyOptionAction2);
+ expect(AppStore.getActiveTopologyOptions().option1).toBe('off');
+ expect(AppStore.getAppState().topologyOptions.option1).toBe('off');
+
+ // other topology w/o options
+ registeredCallback(ClickSubTopologyAction);
+ expect(AppStore.getActiveTopologyOptions().option1).toBeUndefined();
+ expect(AppStore.getAppState().topologyOptions.option1).toBeUndefined();
});
// nodes delta
@@ -166,12 +211,10 @@ describe('AppStore', function() {
registeredCallback(ClickTopologyAction);
registeredCallback(ReceiveNodesDeltaAction);
- expect(AppStore.getAppState())
- .toEqual({"topologyId":"topo1","selectedNodeId": null});
+ expect(AppStore.getAppState().selectedNodeId).toEqual(null);
registeredCallback(ClickNodeAction);
- expect(AppStore.getAppState())
- .toEqual({"topologyId":"topo1","selectedNodeId": 'n1'});
+ expect(AppStore.getAppState().selectedNodeId).toEqual('n1');
// go back in browsing
RouteAction.state = {"topologyId":"topo1","selectedNodeId": null};
@@ -185,16 +228,16 @@ describe('AppStore', function() {
registeredCallback(ClickTopologyAction);
registeredCallback(ReceiveNodesDeltaAction);
- expect(AppStore.getAppState())
- .toEqual({"topologyId":"topo1","selectedNodeId": null});
+ expect(AppStore.getAppState().selectedNodeId).toEqual(null);
+ expect(AppStore.getAppState().topologyId).toEqual('topo1');
registeredCallback(ClickNodeAction);
- expect(AppStore.getAppState())
- .toEqual({"topologyId":"topo1","selectedNodeId": 'n1'});
+ expect(AppStore.getAppState().selectedNodeId).toEqual('n1');
+ expect(AppStore.getAppState().topologyId).toEqual('topo1');
registeredCallback(ClickSubTopologyAction);
- expect(AppStore.getAppState())
- .toEqual({"topologyId":"topo1-grouped","selectedNodeId": null});
+ expect(AppStore.getAppState().selectedNodeId).toEqual(null);
+ expect(AppStore.getAppState().topologyId).toEqual('topo1-grouped');
});
// connection errors
diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js
index 1880b17766..7640505edf 100644
--- a/client/app/scripts/stores/app-store.js
+++ b/client/app/scripts/stores/app-store.js
@@ -43,6 +43,8 @@ function makeNode(node) {
// Initial values
+let activeTopologyOptions = {};
+let currentTopology = null;
let currentTopologyId = 'containers';
let errorUrl = null;
let version = '';
@@ -54,33 +56,60 @@ let selectedNodeId = null;
let topologies = [];
let websocketClosed = true;
+function setTopology(topologyId) {
+ currentTopologyId = topologyId;
+ currentTopology = findCurrentTopology(topologies, topologyId);
+}
+
+function setDefaultTopologyOptions() {
+ activeTopologyOptions = {};
+ if (currentTopology) {
+ _.each(currentTopology.options, function(items, option) {
+ _.each(items, function(item) {
+ if (item.default === true) {
+ activeTopologyOptions[option] = item.value;
+ }
+ });
+ });
+ }
+}
+
// Store API
const AppStore = assign({}, EventEmitter.prototype, {
CHANGE_EVENT: 'change',
+ // keep at the top
getAppState: function() {
return {
topologyId: currentTopologyId,
- selectedNodeId: this.getSelectedNodeId()
+ selectedNodeId: this.getSelectedNodeId(),
+ topologyOptions: this.getActiveTopologyOptions()
};
},
+ getActiveTopologyOptions: function() {
+ return activeTopologyOptions;
+ },
+
getCurrentTopology: function() {
- return findCurrentTopology(topologies, currentTopologyId);
+ if (!currentTopology) {
+ currentTopology = setTopology(currentTopologyId);
+ }
+ return currentTopology;
},
getCurrentTopologyId: function() {
return currentTopologyId;
},
- getCurrentTopologyUrl: function() {
- const topology = this.getCurrentTopology();
+ getCurrentTopologyOptions: function() {
+ return currentTopology && currentTopology.options;
+ },
- if (topology) {
- return topology.url;
- }
+ getCurrentTopologyUrl: function() {
+ return currentTopology && currentTopology.url;
},
getErrorUrl: function() {
@@ -156,6 +185,14 @@ const AppStore = assign({}, EventEmitter.prototype, {
AppStore.registeredCallback = function(payload) {
switch (payload.type) {
+ case ActionTypes.CHANGE_TOPOLOGY_OPTION:
+ if (activeTopologyOptions[payload.option] !== payload.value) {
+ nodes = nodes.clear();
+ }
+ activeTopologyOptions[payload.option] = payload.value;
+ AppStore.emit(AppStore.CHANGE_EVENT);
+ break;
+
case ActionTypes.CLICK_CLOSE_DETAILS:
selectedNodeId = null;
AppStore.emit(AppStore.CHANGE_EVENT);
@@ -169,7 +206,8 @@ AppStore.registeredCallback = function(payload) {
case ActionTypes.CLICK_TOPOLOGY:
selectedNodeId = null;
if (payload.topologyId !== currentTopologyId) {
- currentTopologyId = payload.topologyId;
+ setTopology(payload.topologyId);
+ setDefaultTopologyOptions();
nodes = nodes.clear();
}
AppStore.emit(AppStore.CHANGE_EVENT);
@@ -261,6 +299,10 @@ AppStore.registeredCallback = function(payload) {
case ActionTypes.RECEIVE_TOPOLOGIES:
errorUrl = null;
topologies = payload.topologies;
+ if (!currentTopology) {
+ setTopology(currentTopologyId);
+ setDefaultTopologyOptions();
+ }
AppStore.emit(AppStore.CHANGE_EVENT);
break;
@@ -274,8 +316,10 @@ AppStore.registeredCallback = function(payload) {
if (currentTopologyId !== payload.state.topologyId) {
nodes = nodes.clear();
}
- currentTopologyId = payload.state.topologyId;
+ setTopology(payload.state.topologyId);
+ setDefaultTopologyOptions();
selectedNodeId = payload.state.selectedNodeId;
+ activeTopologyOptions = payload.state.topologyOptions || activeTopologyOptions;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js
index 4400e90338..2eccd6ae56 100644
--- a/client/app/scripts/utils/web-api-utils.js
+++ b/client/app/scripts/utils/web-api-utils.js
@@ -1,3 +1,4 @@
+const _ = require('lodash');
const debug = require('debug')('scope:web-api-utils');
const reqwest = require('reqwest');
@@ -14,17 +15,19 @@ const updateFrequency = '5s';
let socket;
let reconnectTimer = 0;
let currentUrl = null;
+let currentOptions = null;
let topologyTimer = 0;
let apiDetailsTimer = 0;
-function createWebsocket(topologyUrl) {
+function createWebsocket(topologyUrl, optionsQuery) {
if (socket) {
socket.onclose = null;
socket.onerror = null;
socket.close();
}
- socket = new WebSocket(WS_URL + topologyUrl + '/ws?t=' + updateFrequency);
+ socket = new WebSocket(WS_URL + topologyUrl
+ + '/ws?t=' + updateFrequency + '&' + optionsQuery);
socket.onopen = function() {
AppActions.openWebsocket();
@@ -34,7 +37,7 @@ function createWebsocket(topologyUrl) {
clearTimeout(reconnectTimer);
socket = null;
AppActions.closeWebsocket();
- debug('Closed websocket to ' + currentUrl);
+ debug('Closed websocket to ' + topologyUrl);
reconnectTimer = setTimeout(function() {
createWebsocket(topologyUrl);
@@ -42,7 +45,7 @@ function createWebsocket(topologyUrl) {
};
socket.onerror = function() {
- debug('Error in websocket to ' + currentUrl);
+ debug('Error in websocket to ' + topologyUrl);
AppActions.receiveError(currentUrl);
};
@@ -52,8 +55,6 @@ function createWebsocket(topologyUrl) {
AppActions.receiveNodesDelta(msg);
}
};
-
- currentUrl = topologyUrl;
}
/* keep URLs relative */
@@ -75,6 +76,19 @@ function getTopologies() {
});
}
+function getTopology(topologyUrl, options) {
+ const optionsQuery = _.map(options, function(value, param) {
+ return param + '=' + value;
+ }).join('&');
+
+ // only recreate websocket if url changed
+ if (topologyUrl && (topologyUrl !== currentUrl || currentOptions !== optionsQuery)) {
+ createWebsocket(topologyUrl, optionsQuery);
+ currentUrl = topologyUrl;
+ currentOptions = optionsQuery;
+ }
+}
+
function getNodeDetails(topologyUrl, nodeId) {
if (topologyUrl && nodeId) {
const url = [topologyUrl, encodeURIComponent(nodeId)].join('/').substr(1);
@@ -115,10 +129,6 @@ module.exports = {
getApiDetails: getApiDetails,
- getNodesDelta: function(topologyUrl) {
- if (topologyUrl && topologyUrl !== currentUrl) {
- createWebsocket(topologyUrl);
- }
- }
+ getNodesDelta: getTopology
};
diff --git a/client/app/styles/main.less b/client/app/styles/main.less
index 32eaf6e0ad..1eb64c63d2 100644
--- a/client/app/styles/main.less
+++ b/client/app/styles/main.less
@@ -130,6 +130,10 @@ body {
}
+.topology-options {
+ margin-top: -16px;
+}
+
.status {
float: right;
margin-top: 14px;
@@ -269,6 +273,22 @@ body {
height: 100%;
}
+.mui-drop-down-menu {
+ .mui-menu-control {
+ .mui-menu-label {
+ font-size: 12px;
+ }
+
+ .mui-menu-control-underline {
+ border-top: none;
+ }
+
+ .mui-menu-control-bg {
+ background-color: transparent;
+ }
+ }
+}
+
.node-details {
height: 100%;
width: 100%;
diff --git a/render/render.go b/render/render.go
index 1214ffff07..52b693e62e 100644
--- a/render/render.go
+++ b/render/render.go
@@ -1,6 +1,9 @@
package render
import (
+ "strings"
+
+ "github.com/weaveworks/scope/probe/docker"
"github.com/weaveworks/scope/report"
)
@@ -124,7 +127,8 @@ func (m Map) EdgeMetadata(rpt report.Report, srcRenderableID, dstRenderableID st
}
// CustomRenderer allow for mapping functions that recived the entire topology
-// in one call - useful for functions that need to consider the entire graph
+// in one call - useful for functions that need to consider the entire graph.
+// We should minimise the use of this renderer type, as it is very inflexible.
type CustomRenderer struct {
RenderFunc func(RenderableNodes) RenderableNodes
Renderer
@@ -135,68 +139,116 @@ func (c CustomRenderer) Render(rpt report.Report) RenderableNodes {
return c.RenderFunc(c.Renderer.Render(rpt))
}
-// IsConnected is the key added to Node.Metadata by ColorConnected
-// to indicate a node has an edge pointing to it or from it
-const IsConnected = "is_connected"
-
-// OnlyConnected filters out unconnected RenderedNodes
-func OnlyConnected(input RenderableNodes) RenderableNodes {
- output := RenderableNodes{}
- for id, node := range ColorConnected(input) {
- if _, ok := node.Node.Metadata[IsConnected]; ok {
- output[id] = node
- }
- }
- return output
-}
-
-// FilterUnconnected produces a renderer that filters unconnected nodes
-// from the given renderer
-func FilterUnconnected(r Renderer) Renderer {
- return CustomRenderer{
- RenderFunc: OnlyConnected,
- Renderer: r,
- }
-}
-
// ColorConnected colors nodes with the IsConnected key if
// they have edges to or from them.
-func ColorConnected(input RenderableNodes) RenderableNodes {
- connected := map[string]struct{}{}
- void := struct{}{}
-
- for id, node := range input {
- if len(node.Adjacency) == 0 {
- continue
- }
-
- connected[id] = void
- for _, id := range node.Adjacency {
- connected[id] = void
- }
- }
+func ColorConnected(r Renderer) Renderer {
+ return CustomRenderer{
+ Renderer: r,
+ RenderFunc: func(input RenderableNodes) RenderableNodes {
+ connected := map[string]struct{}{}
+ void := struct{}{}
+
+ for id, node := range input {
+ if len(node.Adjacency) == 0 {
+ continue
+ }
+
+ connected[id] = void
+ for _, id := range node.Adjacency {
+ connected[id] = void
+ }
+ }
- for id := range connected {
- node := input[id]
- node.Node.Metadata[IsConnected] = "true"
- input[id] = node
+ for id := range connected {
+ node := input[id]
+ node.Metadata[IsConnected] = "true"
+ input[id] = node
+ }
+ return input
+ },
}
- return input
}
// Filter removes nodes from a view based on a predicate.
type Filter struct {
Renderer
- f func(RenderableNode) bool
+ FilterFunc func(RenderableNode) bool
}
// Render implements Renderer
func (f Filter) Render(rpt report.Report) RenderableNodes {
output := RenderableNodes{}
for id, node := range f.Renderer.Render(rpt) {
- if f.f(node) {
+ if f.FilterFunc(node) {
output[id] = node
}
}
+
+ // Deleted nodes also need to be cut as destinations in adjacency lists.
+ for id, node := range output {
+ newAdjacency := make(report.IDList, 0, len(node.Adjacency))
+ for _, dstID := range node.Adjacency {
+ if _, ok := output[dstID]; ok {
+ newAdjacency = newAdjacency.Add(dstID)
+ }
+ }
+ node.Adjacency = newAdjacency
+ output[id] = node
+ }
return output
}
+
+// IsConnected is the key added to Node.Metadata by ColorConnected
+// to indicate a node has an edge pointing to it or from it
+const IsConnected = "is_connected"
+
+// FilterUnconnected produces a renderer that filters unconnected nodes
+// from the given renderer
+func FilterUnconnected(r Renderer) Renderer {
+ return Filter{
+ Renderer: ColorConnected(r),
+ FilterFunc: func(node RenderableNode) bool {
+ _, ok := node.Metadata[IsConnected]
+ return ok
+ },
+ }
+}
+
+// FilterSystem is a Renderer which filters out system nodes.
+func FilterSystem(r Renderer) Renderer {
+ return Filter{
+ Renderer: r,
+ FilterFunc: func(node RenderableNode) bool {
+ containerName := node.Metadata[docker.ContainerName]
+ if _, ok := systemContainerNames[containerName]; ok {
+ return false
+ }
+ imagePrefix := strings.SplitN(node.Metadata[docker.ImageName], ":", 2)[0] // :(
+ if _, ok := systemImagePrefixes[imagePrefix]; ok {
+ return false
+ }
+ if node.Metadata[docker.LabelPrefix+"works.weave.role"] == "system" {
+ return false
+ }
+ return true
+ },
+ }
+}
+
+var systemContainerNames = map[string]struct{}{
+ "weavescope": {},
+ "weavedns": {},
+ "weave": {},
+ "weaveproxy": {},
+ "weaveexec": {},
+ "ecs-agent": {},
+}
+
+var systemImagePrefixes = map[string]struct{}{
+ "weaveworks/scope": {},
+ "weaveworks/weavedns": {},
+ "weaveworks/weave": {},
+ "weaveworks/weaveproxy": {},
+ "weaveworks/weaveexec": {},
+ "amazon/amazon-ecs-agent": {},
+}
diff --git a/render/render_test.go b/render/render_test.go
index 0d0814f17a..dc89f140f0 100644
--- a/render/render_test.go
+++ b/render/render_test.go
@@ -187,7 +187,29 @@ func TestFilterRender(t *testing.T) {
}
have := expected.Sterilize(renderer.Render(report.MakeReport()))
if !reflect.DeepEqual(want, have) {
- t.Errorf("want %+v, have %+v", want, have)
+ t.Error(test.Diff(want, have))
+ }
+}
+
+func TestFilterRender2(t *testing.T) {
+ // Test adjacencies are removed for filtered nodes.
+ renderer := render.Filter{
+ FilterFunc: func(node render.RenderableNode) bool {
+ return node.ID != "bar"
+ },
+ Renderer: mockRenderer{RenderableNodes: render.RenderableNodes{
+ "foo": {ID: "foo", Node: report.MakeNode().WithAdjacent("bar")},
+ "bar": {ID: "bar", Node: report.MakeNode().WithAdjacent("foo")},
+ "baz": {ID: "baz", Node: report.MakeNode()},
+ }},
+ }
+ want := render.RenderableNodes{
+ "foo": {ID: "foo", Node: report.MakeNode()},
+ "baz": {ID: "baz", Node: report.MakeNode()},
+ }
+ have := expected.Sterilize(renderer.Render(report.MakeReport()))
+ if !reflect.DeepEqual(want, have) {
+ t.Error(test.Diff(want, have))
}
}
diff --git a/render/renderable_node.go b/render/renderable_node.go
index c93b5dde47..cf3917fe53 100644
--- a/render/renderable_node.go
+++ b/render/renderable_node.go
@@ -125,6 +125,15 @@ func (rn RenderableNode) Copy() RenderableNode {
// RenderableNodes is a set of RenderableNodes
type RenderableNodes map[string]RenderableNode
+// Copy produces a deep copy of the RenderableNodes
+func (rns RenderableNodes) Copy() RenderableNodes {
+ result := RenderableNodes{}
+ for key, value := range rns {
+ result[key] = value.Copy()
+ }
+ return result
+}
+
// Merge merges two sets of RenderableNodes, returning a new set.
func (rns RenderableNodes) Merge(other RenderableNodes) RenderableNodes {
result := RenderableNodes{}
diff --git a/render/topologies.go b/render/topologies.go
index e0d6750951..aff608093a 100644
--- a/render/topologies.go
+++ b/render/topologies.go
@@ -85,15 +85,12 @@ var ContainerRenderer = MakeReduce(
// but we need to be careful to ensure we only include each edge once, by only
// including the ProcessRenderer once.
Renderer: Filter{
- f: func(n RenderableNode) bool {
+ FilterFunc: func(n RenderableNode) bool {
_, inContainer := n.Node.Metadata[docker.ContainerID]
_, isConnected := n.Node.Metadata[IsConnected]
return inContainer || isConnected
},
- Renderer: CustomRenderer{
- RenderFunc: ColorConnected,
- Renderer: ProcessRenderer,
- },
+ Renderer: ColorConnected(ProcessRenderer),
},
},
diff --git a/render/topologies_test.go b/render/topologies_test.go
index 87dcbb54d2..a161207dfa 100644
--- a/render/topologies_test.go
+++ b/render/topologies_test.go
@@ -4,6 +4,7 @@ import (
"reflect"
"testing"
+ "github.com/weaveworks/scope/probe/docker"
"github.com/weaveworks/scope/render"
"github.com/weaveworks/scope/render/expected"
"github.com/weaveworks/scope/test"
@@ -33,6 +34,19 @@ func TestContainerRenderer(t *testing.T) {
}
}
+func TestContainerFilterRenderer(t *testing.T) {
+ // tag on of the containers in the topology and ensure
+ // it is filtered out correctly.
+ input := test.Report.Copy()
+ input.Container.Nodes[test.ClientContainerNodeID].Metadata[docker.LabelPrefix+"works.weave.role"] = "system"
+ have := expected.Sterilize(render.FilterSystem(render.ContainerWithImageNameRenderer{}).Render(input))
+ want := expected.RenderedContainers.Copy()
+ delete(want, test.ClientContainerID)
+ if !reflect.DeepEqual(want, have) {
+ t.Error(test.Diff(want, have))
+ }
+}
+
func TestContainerImageRenderer(t *testing.T) {
have := expected.Sterilize(render.ContainerImageRenderer.Render(test.Report))
want := expected.RenderedContainerImages