mirror of
https://github.com/weaveworks/scope.git
synced 2026-08-18 03:46:45 +00:00
Merge pull request #420 from weaveworks/without-system-containers
Add filtering options to views; add options for filtering out system containers & unconnected containers.
This commit is contained in:
+37
-8
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
<div className="header">
|
||||
<Logo />
|
||||
<Topologies topologies={this.state.topologies} currentTopology={this.state.currentTopology} />
|
||||
<TopologyOptions options={this.state.currentTopologyOptions}
|
||||
activeOptions={this.state.activeTopologyOptions} />
|
||||
<Status errorUrl={this.state.errorUrl} websocketClosed={this.state.websocketClosed} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<DropDownMenu menuItems={menuItems} onChange={this.onChange} key={key}
|
||||
selectedIndex={selected} />
|
||||
);
|
||||
},
|
||||
|
||||
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 (
|
||||
<div className="topology-options" ref="container">
|
||||
{options.map(function(items) {
|
||||
return this.renderOption(items);
|
||||
}, this)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
+97
-45
@@ -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{}{}
|
||||
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
|
||||
}
|
||||
for id, node := range input {
|
||||
if len(node.Adjacency) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
connected[id] = void
|
||||
for _, id := range node.Adjacency {
|
||||
connected[id] = void
|
||||
}
|
||||
connected[id] = void
|
||||
for _, id := range node.Adjacency {
|
||||
connected[id] = void
|
||||
}
|
||||
}
|
||||
|
||||
for id := range connected {
|
||||
node := input[id]
|
||||
node.Metadata[IsConnected] = "true"
|
||||
input[id] = node
|
||||
}
|
||||
return input
|
||||
},
|
||||
}
|
||||
|
||||
for id := range connected {
|
||||
node := input[id]
|
||||
node.Node.Metadata[IsConnected] = "true"
|
||||
input[id] = node
|
||||
}
|
||||
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": {},
|
||||
}
|
||||
|
||||
+23
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user