Merge pull request #2404 from weaveworks/1421-multiple-namespaces

Allow the user to view multiple Kubernetes namespaces at once
This commit is contained in:
Jordan Pellizzari
2017-03-27 13:53:05 -07:00
committed by GitHub
9 changed files with 360 additions and 96 deletions

View File

@@ -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 &param=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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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 (
<div className={className} onClick={this.onClick}>
{item.get('label')}
@@ -27,8 +25,3 @@ class TopologyOptionAction extends React.Component {
);
}
}
export default connect(
null,
{ changeTopologyOption }
)(TopologyOptionAction);

View File

@@ -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 (
<div className="topology-option" key={optionId}>
<div className="topology-option-wrapper">
{option.get('options').map(item => <TopologyOptionAction
optionId={optionId} topologyId={topologyId} key={item.get('value')}
activeValue={activeValue} item={item} />)}
{option.get('options').map(item => (
<TopologyOptionAction
onClick={this.handleOptionClick}
optionId={optionId}
topologyId={topologyId}
key={item.get('value')}
activeValue={activeValue}
item={item}
/>
))}
{option.get('selectType') === 'union' &&
<TopologyOptionAction
onClick={this.handleNoneClick}
optionId={optionId}
item={noneItem}
topologyId={topologyId}
activeValue={activeValue}
/>
}
</div>
</div>
);
}
render() {
const { options } = this.props;
return (
<div className="topology-options">
{this.props.options && this.props.options.toIndexedSeq().map(
{options && options.toIndexedSeq().map(
option => this.renderOption(option))}
</div>
);
@@ -43,5 +108,6 @@ function mapStateToProps(state) {
}
export default connect(
mapStateToProps
mapStateToProps,
{ changeTopologyOption }
)(TopologyOptions);

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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 '';
}

View File

@@ -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 {