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 {