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