mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-03 18:20:27 +00:00
Merge pull request #2404 from weaveworks/1421-multiple-namespaces
Allow the user to view multiple Kubernetes namespaces at once
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user