diff --git a/app/api_topologies.go b/app/api_topologies.go
index 4d0a38e2f..d63edd77d 100644
--- a/app/api_topologies.go
+++ b/app/api_topologies.go
@@ -5,8 +5,10 @@ import (
"net/http"
"net/url"
"sort"
+ "strings"
"sync"
+ log "github.com/Sirupsen/logrus"
"github.com/gorilla/mux"
"golang.org/x/net/context"
@@ -48,7 +50,7 @@ var (
// kubernetesFilters generates the current kubernetes filters based on the
// available k8s topologies.
func kubernetesFilters(namespaces ...string) APITopologyOptionGroup {
- options := APITopologyOptionGroup{ID: "namespace", Default: "all"}
+ options := APITopologyOptionGroup{ID: "namespace", Default: "", SelectType: "union", NoneLabel: "All Namespaces"}
for _, namespace := range namespaces {
if namespace == "default" {
options.Default = namespace
@@ -57,7 +59,6 @@ func kubernetesFilters(namespaces ...string) APITopologyOptionGroup {
Value: namespace, Label: namespace, filter: render.IsNamespace(namespace), filterPseudo: false,
})
}
- options.Options = append(options.Options, APITopologyOption{Value: "all", Label: "All Namespaces", filter: nil, filterPseudo: false})
return options
}
@@ -292,9 +293,62 @@ func (a byName) Less(i, j int) bool { return a[i].Name < a[j].Name }
// APITopologyOptionGroup describes a group of APITopologyOptions
type APITopologyOptionGroup struct {
- ID string `json:"id"`
+ ID string `json:"id"`
+ // Default value for the UI to adopt. NOT used as the default if the value is omitted, allowing "" as a distinct value.
Default string `json:"defaultValue,omitempty"`
Options []APITopologyOption `json:"options,omitempty"`
+ // SelectType describes how options can be picked. Currently defined values:
+ // "one": Default if empty. Exactly one option may be picked from the list.
+ // "union": Any number of options may be picked. Nodes matching any option filter selected are displayed.
+ // Value and Default should be a ","-separated list.
+ SelectType string `json:"selectType,omitempty"`
+ // For "union" type, this is the label the UI should use to represent the case where nothing is selected
+ NoneLabel string `json:"noneLabel,omitempty"`
+}
+
+// Get the render filters to use for this option group as a Decorator, if any.
+// If second arg is false, no decorator was needed.
+func (g APITopologyOptionGroup) getFilterDecorator(value string) (render.Decorator, bool) {
+ selectType := g.SelectType
+ if selectType == "" {
+ selectType = "one"
+ }
+ var values []string
+ switch selectType {
+ case "one":
+ values = []string{value}
+ case "union":
+ values = strings.Split(value, ",")
+ default:
+ log.Errorf("Invalid select type %s for option group %s, ignoring option", selectType, g.ID)
+ return nil, false
+ }
+ filters := []render.FilterFunc{}
+ for _, opt := range g.Options {
+ for _, v := range values {
+ if v != opt.Value {
+ continue
+ }
+ var filter render.FilterFunc
+ if opt.filter == nil {
+ // No filter means match everything (pseudo doesn't matter)
+ filter = func(n report.Node) bool { return true }
+ } else if opt.filterPseudo {
+ // Apply filter to pseudo topologies also
+ filter = opt.filter
+ } else {
+ // Allow all pseudo topology nodes, only apply filter to non-pseudo
+ filter = render.AnyFilterFunc(render.IsPseudoTopology, opt.filter)
+ }
+ filters = append(filters, filter)
+ }
+ }
+ if len(filters) == 0 {
+ return nil, false
+ }
+ // Since we've encoded whether to ignore pseudo topologies into each subfilter,
+ // we want no special behaviour for pseudo topologies here, which corresponds to MakePseudo
+ return render.MakeFilterPseudoDecorator(render.AnyFilterFunc(filters...)), true
}
// APITopologyOption describes a ¶m=value to a given topology.
@@ -437,17 +491,8 @@ func (r *Registry) RendererForTopology(topologyID string, values url.Values, rpt
var decorators []render.Decorator
for _, group := range topology.Options {
value := values.Get(group.ID)
- for _, opt := range group.Options {
- if opt.filter == nil {
- continue
- }
- if (value == "" && group.Default == opt.Value) || (opt.Value != "" && opt.Value == value) {
- if opt.filterPseudo {
- decorators = append(decorators, render.MakeFilterPseudoDecorator(opt.filter))
- } else {
- decorators = append(decorators, render.MakeFilterDecorator(opt.filter))
- }
- }
+ if decorator, ok := group.getFilterDecorator(value); ok {
+ decorators = append(decorators, decorator)
}
}
if len(decorators) > 0 {
diff --git a/app/api_topologies_test.go b/app/api_topologies_test.go
index ed28e0c33..6ce9b49f1 100644
--- a/app/api_topologies_test.go
+++ b/app/api_topologies_test.go
@@ -106,6 +106,8 @@ func TestRendererForTopologyWithFiltering(t *testing.T) {
urlvalues := url.Values{}
urlvalues.Set(systemGroupID, customAPITopologyOptionFilterID)
+ urlvalues.Set("stopped", "running")
+ urlvalues.Set("pseudo", "hide")
renderer, decorator, err := topologyRegistry.RendererForTopology("containers", urlvalues, fixture.Report)
if err != nil {
t.Fatalf("Topology Registry Report error: %s", err)
@@ -135,6 +137,8 @@ func TestRendererForTopologyNoFiltering(t *testing.T) {
urlvalues := url.Values{}
urlvalues.Set(systemGroupID, customAPITopologyOptionFilterID)
+ urlvalues.Set("stopped", "running")
+ urlvalues.Set("pseudo", "hide")
renderer, decorator, err := topologyRegistry.RendererForTopology("containers", urlvalues, fixture.Report)
if err != nil {
t.Fatalf("Topology Registry Report error: %s", err)
@@ -171,6 +175,8 @@ func getTestContainerLabelFilterTopologySummary(t *testing.T, exclude bool) (det
urlvalues := url.Values{}
urlvalues.Set(systemGroupID, customAPITopologyOptionFilterID)
+ urlvalues.Set("stopped", "running")
+ urlvalues.Set("pseudo", "hide")
renderer, decorator, err := topologyRegistry.RendererForTopology("containers", urlvalues, fixture.Report)
if err != nil {
return nil, err
diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js
index badb484d1..ae25c8770 100644
--- a/client/app/scripts/actions/app-actions.js
+++ b/client/app/scripts/actions/app-actions.js
@@ -174,13 +174,14 @@ export function blurSearch() {
return { type: ActionTypes.BLUR_SEARCH };
}
-export function changeTopologyOption(option, value, topologyId) {
+export function changeTopologyOption(option, value, topologyId, addOrRemove) {
return (dispatch, getState) => {
dispatch({
type: ActionTypes.CHANGE_TOPOLOGY_OPTION,
topologyId,
option,
- value
+ value,
+ addOrRemove
});
updateRoute(getState);
// update all request workers with new options
diff --git a/client/app/scripts/components/topology-option-action.js b/client/app/scripts/components/topology-option-action.js
index f8ee59770..1bb090d38 100644
--- a/client/app/scripts/components/topology-option-action.js
+++ b/client/app/scripts/components/topology-option-action.js
@@ -1,9 +1,6 @@
import React from 'react';
-import { connect } from 'react-redux';
-import { changeTopologyOption } from '../actions/app-actions';
-
-class TopologyOptionAction extends React.Component {
+export default class TopologyOptionAction extends React.Component {
constructor(props, context) {
super(props, context);
@@ -13,13 +10,14 @@ class TopologyOptionAction extends React.Component {
onClick(ev) {
ev.preventDefault();
const { optionId, topologyId, item } = this.props;
- this.props.changeTopologyOption(optionId, item.get('value'), topologyId);
+ this.props.onClick(optionId, item.get('value'), topologyId);
}
render() {
const { activeValue, item } = this.props;
- const className = activeValue === item.get('value')
- ? 'topology-option-action topology-option-action-selected' : 'topology-option-action';
+ const className = activeValue.includes(item.get('value'))
+ ? 'topology-option-action topology-option-action-selected'
+ : 'topology-option-action';
return (
{item.get('label')}
@@ -27,8 +25,3 @@ class TopologyOptionAction extends React.Component {
);
}
}
-
-export default connect(
- null,
- { changeTopologyOption }
-)(TopologyOptionAction);
diff --git a/client/app/scripts/components/topology-options.js b/client/app/scripts/components/topology-options.js
index 2c572060a..f16c8f923 100644
--- a/client/app/scripts/components/topology-options.js
+++ b/client/app/scripts/components/topology-options.js
@@ -1,33 +1,98 @@
import React from 'react';
import { connect } from 'react-redux';
+import { Map as makeMap } from 'immutable';
+import includes from 'lodash/includes';
import { getCurrentTopologyOptions } from '../utils/topology-utils';
import { activeTopologyOptionsSelector } from '../selectors/topology';
import TopologyOptionAction from './topology-option-action';
+import { changeTopologyOption } from '../actions/app-actions';
class TopologyOptions extends React.Component {
+ constructor(props, context) {
+ super(props, context);
+
+ this.handleOptionClick = this.handleOptionClick.bind(this);
+ this.handleNoneClick = this.handleNoneClick.bind(this);
+ }
+
+ handleOptionClick(optionId, value, topologyId) {
+ let nextOptions = [value];
+ const { activeOptions, options } = this.props;
+ const selectedOption = options.find(o => o.get('id') === optionId);
+
+ if (selectedOption.get('selectType') === 'union') {
+ // Multi-select topology options (such as k8s namespaces) are handled here.
+ // Users can select one, many, or none of these options.
+ // The component builds an array of the next selected values that are sent to the action.
+ const opts = activeOptions.toJS();
+ const selected = selectedOption.get('id');
+ const isSelectedAlready = includes(opts[selected], value);
+
+ if (isSelectedAlready) {
+ // Remove the option if it is already selected
+ nextOptions = opts[selected].filter(o => o !== value);
+ } else {
+ // Add it to the array if it's not selected
+ nextOptions = opts[selected].concat(value);
+ }
+ // Since the user is clicking an option, remove the highlighting from the 'none' option,
+ // unless they are removing the last option. In that case, default to the 'none' label.
+ if (nextOptions.length === 0) {
+ nextOptions = ['none'];
+ } else {
+ nextOptions = nextOptions.filter(o => o !== 'none');
+ }
+ }
+ this.props.changeTopologyOption(optionId, nextOptions, topologyId);
+ }
+
+ handleNoneClick(optionId, value, topologyId) {
+ this.props.changeTopologyOption(optionId, ['none'], topologyId);
+ }
renderOption(option) {
const { activeOptions, topologyId } = this.props;
const optionId = option.get('id');
const activeValue = activeOptions && activeOptions.has(optionId)
- ? activeOptions.get(optionId) : option.get('defaultValue');
-
+ ? activeOptions.get(optionId)
+ : option.get('defaultValue');
+ const noneItem = makeMap({
+ value: 'none',
+ label: option.get('noneLabel')
+ });
return (
- {option.get('options').map(item => )}
+ {option.get('options').map(item => (
+
+ ))}
+ {option.get('selectType') === 'union' &&
+
+ }
);
}
render() {
+ const { options } = this.props;
return (
- {this.props.options && this.props.options.toIndexedSeq().map(
+ {options && options.toIndexedSeq().map(
option => this.renderOption(option))}
);
@@ -43,5 +108,6 @@ function mapStateToProps(state) {
}
export default connect(
- mapStateToProps
+ mapStateToProps,
+ { changeTopologyOption }
)(TopologyOptions);
diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js
index 58441f5f2..45f46daee 100644
--- a/client/app/scripts/reducers/__tests__/root-test.js
+++ b/client/app/scripts/reducers/__tests__/root-test.js
@@ -1,4 +1,6 @@
import { is, fromJS } from 'immutable';
+import expect from 'expect';
+
import { TABLE_VIEW_MODE } from '../../constants/naming';
// Root reducer test suite using Jasmine matchers
import { constructEdgeId } from '../../utils/layouter-utils';
@@ -46,33 +48,118 @@ describe('RootReducer', () => {
}
};
- const topologies = [{
- hide_if_empty: true,
- name: 'Processes',
- rank: 1,
- sub_topologies: [],
- url: '/api/topology/processes',
- fullName: 'Processes',
- id: 'processes',
- options: [
- {
- defaultValue: 'hide',
- id: 'unconnected',
- options: [
- {
- label: 'Unconnected nodes hidden',
- value: 'hide'
- }
- ]
+ const topologies = [
+ {
+ hide_if_empty: true,
+ name: 'Processes',
+ rank: 1,
+ sub_topologies: [],
+ url: '/api/topology/processes',
+ fullName: 'Processes',
+ id: 'processes',
+ options: [
+ {
+ defaultValue: 'hide',
+ id: 'unconnected',
+ selectType: 'one',
+ options: [
+ {
+ label: 'Unconnected nodes hidden',
+ value: 'hide'
+ }
+ ]
+ }
+ ],
+ stats: {
+ edge_count: 379,
+ filtered_nodes: 214,
+ node_count: 320,
+ nonpseudo_node_count: 320
}
- ],
- stats: {
- edge_count: 379,
- filtered_nodes: 214,
- node_count: 320,
- nonpseudo_node_count: 320
+ },
+ {
+ hide_if_empty: true,
+ name: 'Pods',
+ options: [
+ {
+ defaultValue: 'default',
+ id: 'namespace',
+ selectType: 'many',
+ options: [
+ {
+ label: 'monitoring',
+ value: 'monitoring'
+ },
+ {
+ label: 'scope',
+ value: 'scope'
+ },
+ {
+ label: 'All Namespaces',
+ value: 'all'
+ }
+ ]
+ },
+ {
+ defaultValue: 'hide',
+ id: 'pseudo',
+ options: [
+ {
+ label: 'Show Unmanaged',
+ value: 'show'
+ },
+ {
+ label: 'Hide Unmanaged',
+ value: 'hide'
+ }
+ ]
+ }
+ ],
+ rank: 3,
+ stats: {
+ edge_count: 15,
+ filtered_nodes: 16,
+ node_count: 32,
+ nonpseudo_node_count: 27
+ },
+ sub_topologies: [
+ {
+ hide_if_empty: true,
+ name: 'services',
+ options: [
+ {
+ defaultValue: 'default',
+ id: 'namespace',
+ selectType: 'many',
+ options: [
+ {
+ label: 'monitoring',
+ value: 'monitoring'
+ },
+ {
+ label: 'scope',
+ value: 'scope'
+ },
+ {
+ label: 'All Namespaces',
+ value: 'all'
+ }
+ ]
+ }
+ ],
+ rank: 0,
+ stats: {
+ edge_count: 14,
+ filtered_nodes: 16,
+ node_count: 159,
+ nonpseudo_node_count: 154
+ },
+ url: '/api/topology/services'
+ }
+ ],
+ url: '/api/topology/pods'
}
- }];
+ ];
// actions
@@ -80,14 +167,14 @@ describe('RootReducer', () => {
type: ActionTypes.CHANGE_TOPOLOGY_OPTION,
topologyId: 'topo1',
option: 'option1',
- value: 'on'
+ value: ['on']
};
const ChangeTopologyOptionAction2 = {
type: ActionTypes.CHANGE_TOPOLOGY_OPTION,
topologyId: 'topo1',
option: 'option1',
- value: 'off'
+ value: ['off']
};
const ClickNodeAction = {
@@ -222,7 +309,16 @@ describe('RootReducer', () => {
expect(nextState.get('topologies').size).toBe(2);
expect(nextState.get('currentTopology').get('name')).toBe('Topo1');
expect(nextState.get('currentTopology').get('url')).toBe('/topo1');
- expect(nextState.get('currentTopology').get('options').first().get('id')).toBe('option1');
+ expect(nextState.get('currentTopology').get('options').first().get('id')).toEqual(['option1']);
+ expect(nextState.getIn(['currentTopology', 'options']).toJS()).toEqual([{
+ id: 'option1',
+ defaultValue: 'off',
+ selectType: 'one',
+ options: [
+ { value: 'on'},
+ { value: 'off'}
+ ]
+ }]);
});
it('get sub-topology', () => {
@@ -233,7 +329,7 @@ describe('RootReducer', () => {
expect(nextState.get('topologies').size).toBe(2);
expect(nextState.get('currentTopology').get('name')).toBe('topo 1 grouped');
expect(nextState.get('currentTopology').get('url')).toBe('/topo1-grouped');
- expect(nextState.get('currentTopology').get('options')).toBeUndefined();
+ expect(nextState.get('currentTopology').get('options')).toNotExist();
});
// topology options
@@ -245,28 +341,57 @@ describe('RootReducer', () => {
// default options
expect(activeTopologyOptionsSelector(nextState).has('option1')).toBeTruthy();
- expect(activeTopologyOptionsSelector(nextState).get('option1')).toBe('off');
- expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off');
+ expect(activeTopologyOptionsSelector(nextState).get('option1')).toBeA('array');
+ expect(activeTopologyOptionsSelector(nextState).get('option1')).toEqual(['off']);
+ expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['off']);
// turn on
nextState = reducer(nextState, ChangeTopologyOptionAction);
- expect(activeTopologyOptionsSelector(nextState).get('option1')).toBe('on');
- expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('on');
+ expect(activeTopologyOptionsSelector(nextState).get('option1')).toEqual(['on']);
+ expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['on']);
// turn off
nextState = reducer(nextState, ChangeTopologyOptionAction2);
- expect(activeTopologyOptionsSelector(nextState).get('option1')).toBe('off');
- expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off');
+ expect(activeTopologyOptionsSelector(nextState).get('option1')).toEqual(['off']);
+ expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['off']);
// sub-topology should retain main topo options
nextState = reducer(nextState, ClickSubTopologyAction);
- expect(activeTopologyOptionsSelector(nextState).get('option1')).toBe('off');
- expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off');
+ expect(activeTopologyOptionsSelector(nextState).get('option1')).toEqual(['off']);
+ expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['off']);
// other topology w/o options dont return options, but keep in app state
nextState = reducer(nextState, ClickTopology2Action);
- expect(activeTopologyOptionsSelector(nextState)).toBeUndefined();
- expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off');
+ expect(activeTopologyOptionsSelector(nextState)).toNotExist();
+ expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['off']);
+ });
+
+ it('adds/removes a topology option', () => {
+ const addAction = {
+ type: ActionTypes.CHANGE_TOPOLOGY_OPTION,
+ topologyId: 'services',
+ option: 'namespace',
+ value: ['default', 'scope'],
+ };
+ const removeAction = {
+ type: ActionTypes.CHANGE_TOPOLOGY_OPTION,
+ topologyId: 'services',
+ option: 'namespace',
+ value: ['default']
+ };
+ let nextState = initialState;
+ nextState = reducer(nextState, { type: ActionTypes.RECEIVE_TOPOLOGIES, topologies});
+ nextState = reducer(nextState, { type: ActionTypes.CLICK_TOPOLOGY, topologyId: 'services' });
+ nextState = reducer(nextState, addAction);
+ expect(activeTopologyOptionsSelector(nextState).toJS()).toEqual({
+ namespace: ['default', 'scope'],
+ pseudo: ['hide']
+ });
+ nextState = reducer(nextState, removeAction);
+ expect(activeTopologyOptionsSelector(nextState).toJS()).toEqual({
+ namespace: ['default'],
+ pseudo: ['hide']
+ });
});
it('sets topology options from route', () => {
@@ -296,8 +421,8 @@ describe('RootReducer', () => {
nextState = reducer(nextState, RouteAction);
nextState = reducer(nextState, ReceiveTopologiesAction);
nextState = reducer(nextState, ClickTopologyAction);
- expect(activeTopologyOptionsSelector(nextState).get('option1')).toBe('off');
- expect(getUrlState(nextState).topologyOptions.topo1.option1).toBe('off');
+ expect(activeTopologyOptionsSelector(nextState).get('option1')).toEqual(['off']);
+ expect(getUrlState(nextState).topologyOptions.topo1.option1).toEqual(['off']);
});
// nodes delta
@@ -315,7 +440,7 @@ describe('RootReducer', () => {
it('shows nodes that were received', () => {
let nextState = initialState;
nextState = reducer(nextState, ReceiveNodesDeltaAction);
- expect(nextState.get('nodes').toJS()).toEqual(NODE_SET);
+ expect(nextState.get('nodes').toJS()).toInclude(NODE_SET);
});
it('knows a route was set', () => {
@@ -331,11 +456,11 @@ describe('RootReducer', () => {
nextState = reducer(nextState, ClickNodeAction);
expect(nextState.get('selectedNodeId')).toBe('n1');
- expect(nextState.get('nodes').toJS()).toEqual(NODE_SET);
+ expect(nextState.get('nodes').toJS()).toInclude(NODE_SET);
nextState = reducer(nextState, deSelectNode);
expect(nextState.get('selectedNodeId')).toBe(null);
- expect(nextState.get('nodes').toJS()).toEqual(NODE_SET);
+ expect(nextState.get('nodes').toJS()).toInclude(NODE_SET);
});
it('keeps showing nodes on navigating back after node click', () => {
@@ -352,7 +477,7 @@ describe('RootReducer', () => {
RouteAction.state = {topologyId: 'topo1', selectedNodeId: null};
nextState = reducer(nextState, RouteAction);
expect(nextState.get('selectedNodeId')).toBe(null);
- expect(nextState.get('nodes').toJS()).toEqual(NODE_SET);
+ expect(nextState.get('nodes').toJS()).toInclude(NODE_SET);
});
it('closes details when changing topologies', () => {
@@ -378,12 +503,12 @@ describe('RootReducer', () => {
it('resets topology on websocket reconnect', () => {
let nextState = initialState;
nextState = reducer(nextState, ReceiveNodesDeltaAction);
- expect(nextState.get('nodes').toJS()).toEqual(NODE_SET);
+ expect(nextState.get('nodes').toJS()).toInclude(NODE_SET);
nextState = reducer(nextState, CloseWebsocketAction);
expect(nextState.get('websocketClosed')).toBeTruthy();
// keep showing old nodes
- expect(nextState.get('nodes').toJS()).toEqual(NODE_SET);
+ expect(nextState.get('nodes').toJS()).toInclude(NODE_SET);
nextState = reducer(nextState, OpenWebsocketAction);
expect(nextState.get('websocketClosed')).toBeFalsy();
diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js
index 7221a1d41..199645ea4 100644
--- a/client/app/scripts/reducers/root.js
+++ b/client/app/scripts/reducers/root.js
@@ -1,6 +1,6 @@
/* eslint-disable import/no-webpack-loader-syntax, import/no-unresolved */
import debug from 'debug';
-import { size, each, includes } from 'lodash';
+import { size, each, includes, isEqual } from 'lodash';
import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap,
OrderedMap as makeOrderedMap, Set as makeSet } from 'immutable';
@@ -87,15 +87,32 @@ export const initialState = makeMap({
zoomCache: makeMap(),
});
+function calcSelectType(topology) {
+ const result = {
+ ...topology,
+ options: topology.options && topology.options.map((option) => {
+ // Server doesn't return the `selectType` key unless the option is something other than `one`.
+ // Default to `one` if undefined, so the component doesn't have to handle this.
+ option.selectType = option.selectType || 'one';
+ return option;
+ })
+ };
+
+ if (topology.sub_topologies) {
+ result.sub_topologies = topology.sub_topologies.map(calcSelectType);
+ }
+ return result;
+}
+
// adds ID field to topology (based on last part of URL path) and save urls in
// map for easy lookup
function processTopologies(state, nextTopologies) {
// filter out hidden topos
const visibleTopologies = filterHiddenTopologies(nextTopologies);
-
+ // set `selectType` field for topology and sub_topologies options (recursive).
+ const topologiesWithSelectType = visibleTopologies.map(calcSelectType);
// add IDs to topology objects in-place
- const topologiesWithId = updateTopologyIds(visibleTopologies);
-
+ const topologiesWithId = updateTopologyIds(topologiesWithSelectType);
// cache URLs by ID
state = state.set('topologyUrlsById',
setTopologyUrlsById(state.get('topologyUrlsById'), topologiesWithId));
@@ -106,8 +123,7 @@ function processTopologies(state, nextTopologies) {
}
function setTopology(state, topologyId) {
- state = state.set('currentTopology', findTopologyById(
- state.get('topologies'), topologyId));
+ state = state.set('currentTopology', findTopologyById(state.get('topologies'), topologyId));
return state.set('currentTopologyId', topologyId);
}
@@ -118,7 +134,7 @@ function setDefaultTopologyOptions(state, topologyList) {
topology.get('options').forEach((option) => {
const optionId = option.get('id');
const defaultValue = option.get('defaultValue');
- defaultOptions = defaultOptions.set(optionId, defaultValue);
+ defaultOptions = defaultOptions.set(optionId, [defaultValue]);
});
}
@@ -179,13 +195,14 @@ export function rootReducer(state = initialState, action) {
const topology = findTopologyById(state.get('topologies'), action.topologyId);
if (topology) {
const topologyId = topology.get('parentId') || topology.get('id');
- if (state.getIn(['topologyOptions', topologyId, action.option]) !== action.value) {
+ const optionKey = ['topologyOptions', topologyId, action.option];
+ const currentOption = state.getIn(['topologyOptions', topologyId, action.option]);
+
+ if (!isEqual(currentOption, action.value)) {
state = clearNodes(state);
}
- state = state.setIn(
- ['topologyOptions', topologyId, action.option],
- action.value
- );
+
+ state = state.setIn(optionKey, action.value);
}
return state;
}
diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js
index c7df5d2cd..41e9fa7e2 100644
--- a/client/app/scripts/utils/web-api-utils.js
+++ b/client/app/scripts/utils/web-api-utils.js
@@ -1,7 +1,7 @@
import debug from 'debug';
import reqwest from 'reqwest';
import defaults from 'lodash/defaults';
-import { Map as makeMap } from 'immutable';
+import { Map as makeMap, List } from 'immutable';
import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveError,
receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError,
@@ -45,7 +45,12 @@ let continuePolling = true;
export function buildOptionsQuery(options) {
if (options) {
- return options.map((value, param) => `${param}=${value}`).join('&');
+ return options.map((value, param) => {
+ if (List.isList(value)) {
+ value = value.join(',');
+ }
+ return `${param}=${value}`;
+ }).join('&');
}
return '';
}
diff --git a/render/filters.go b/render/filters.go
index fc5425d46..f7df8d337 100644
--- a/render/filters.go
+++ b/render/filters.go
@@ -321,6 +321,12 @@ func IsNotPseudo(n report.Node) bool {
return n.Topology != Pseudo || strings.HasSuffix(n.ID, TheInternetID) || strings.HasPrefix(n.ID, ServiceNodeIDPrefix)
}
+// IsPseudoTopology returns true if the node is in a pseudo topology,
+// mimicing the check performed by MakeFilter() instead of the more complex check in IsNotPseudo()
+func IsPseudoTopology(n report.Node) bool {
+ return n.Topology == Pseudo
+}
+
// IsNamespace checks if the node is a pod/service in the specified namespace
func IsNamespace(namespace string) FilterFunc {
return func(n report.Node) bool {