UI - replication path filtering (#7620)

* rename mount-filter-config models, components, serializer, adapters to path-filter-config

* move search-select component to core addon

* add js class for search-select-placeholder and sort out power-select deps for moving to the core component

* expose oninput from powerselect through search-select

* don't fetch mounts in the replication routes

* remove toggle from add template

* start cross-namespace fetching

* group options and set up for namespace fetch via power-select search prop

* add and style up radio-card CSS component

* add xlm size for icons between l and xl

* copy defaults so they're not getting mutated

* finalize cross-namespace fetching and getting that to work with power-select

* when passing options but no models, format the options in search select so that they render properly in the list

* tint the background of a selected radio card

* default to null mode and uniq options in search-select

* finish styling radio-card

* format inputValues when first rendering the component if options are being passed from outside

* treat mode:null as deleting existing config which simplifies save logic

* correctly prune the auto complete list since path-filter-config-list handles all of that and finish styling

* remove old component

* add search debounce and fix linting

* update search-select docs

* updating tests

* support grouped options for when to show the create prompt

* update and add tests for path-filter-config-list

* fix tests for search-select and path-filter-config-list

* the new api uses allow/deny instead of whitelist/blacklist
This commit is contained in:
Matthew Irish 2019-10-25 13:16:45 -05:00 committed by GitHub
parent ee2e3fd75d
commit eae5e114ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 620 additions and 300 deletions

View File

@ -2,7 +2,7 @@ import ApplicationAdapter from './application';
export default ApplicationAdapter.extend({
url(id) {
return `${this.buildURL()}/replication/performance/primary/mount-filter/${id}`;
return `${this.buildURL()}/replication/performance/primary/paths-filter/${id}`;
},
findRecord(store, type, id) {

View File

@ -2,9 +2,7 @@ import DS from 'ember-data';
const { attr } = DS;
export default DS.Model.extend({
mode: attr('string', {
defaultValue: 'whitelist',
}),
mode: attr('string'),
paths: attr('array', {
defaultValue: function() {
return [];

View File

@ -30,6 +30,10 @@
height: 20px;
}
.hs-icon-xlm {
width: 24px;
height: 24px;
}
.hs-icon-xl {
width: 28px;
height: 28px;

View File

@ -0,0 +1,90 @@
.radio-card-selector {
display: flex;
margin-bottom: $spacing-xs;
}
.radio-card {
width: 19rem;
box-shadow: $box-shadow-low;
display: flex;
flex-direction: column;
justify-content: space-between;
margin: $spacing-xs $spacing-m;
border: $base-border;
border-radius: $radius;
transition: all ease-in-out $speed;
input[type='radio'] {
position: absolute;
z-index: 1;
opacity: 0;
}
input[type='radio'] + label {
border: 1px solid $grey-light;
border-radius: 50%;
cursor: pointer;
display: block;
height: 1rem;
width: 1rem;
flex-shrink: 0;
flex-grow: 0;
}
input[type='radio']:checked + label {
background: $blue;
border: 1px solid $blue;
box-shadow: inset 0 0 0 0.15rem $white;
}
input[type='radio']:focus + label {
box-shadow: 0 0 10px 1px rgba($blue, 0.4), inset 0 0 0 0.15rem $white;
}
}
.radio-card:first-child {
margin-left: 0;
}
.radio-card:last-child {
margin-right: 0;
}
.radio-card-row {
display: flex;
padding: $spacing-m;
}
.radio-card-icon {
color: $ui-gray-300;
}
.radio-card-message {
margin: $spacing-xxs;
}
.radio-card-message-title {
font-weight: $font-weight-semibold;
font-size: $size-7;
margin-bottom: $spacing-xxs;
}
.radio-card-message-body {
line-height: 1.2;
color: $ui-gray-500;
font-size: $size-8;
}
.radio-card-radio-row {
display: flex;
justify-content: center;
background: $ui-gray-050;
padding: $spacing-xs;
}
.is-selected {
&.radio-card {
border-color: $blue-500;
background: $ui-gray-010;
box-shadow: $box-shadow-middle;
}
.radio-card-icon {
color: $black;
}
.radio-card-radio-row {
background: $blue-050;
}
}

View File

@ -56,7 +56,7 @@
padding-left: $spacing-xxs + $spacing-l;
}
.ember-power-select-options {
div > .ember-power-select-options {
background: $white;
border: $base-border;
box-shadow: $box-shadow-middle;

View File

@ -72,6 +72,7 @@
@import './components/navigate-input';
@import './components/page-header';
@import './components/popup-menu';
@import './components/radio-card';
@import './components/radial-progress';
@import './components/raft-join';
@import './components/role-item';

View File

@ -7,7 +7,7 @@
* <Icon @glyph="cancel-square-outline" />
* ```
* @param glyph=null {String} - The name of the SVG to render inline.
* @param [size='m'] {String} - The size of the Icon, can be one of 's', 'm', 'l', 'xl', 'xxl'. The default is 'm'.
* @param [size='m'] {String} - The size of the Icon, can be one of 's', 'm', 'l', 'xlm', 'xl', 'xxl'. The default is 'm'.
*
*/
import Component from '@ember/component';
@ -15,7 +15,7 @@ import { computed } from '@ember/object';
import { assert } from '@ember/debug';
import layout from '../templates/components/icon';
const SIZES = ['s', 'm', 'l', 'xl', 'xxl'];
const SIZES = ['s', 'm', 'l', 'xlm', 'xl', 'xxl'];
export default Component.extend({
tagName: '',

View File

@ -0,0 +1,6 @@
import Component from '@ember/component';
import layout from '../templates/components/search-select-placeholder';
export default Component.extend({
layout,
});

View File

@ -3,6 +3,7 @@ import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { computed } from '@ember/object';
import { singularize } from 'ember-inflector';
import layout from '../templates/components/search-select';
/**
* @module SearchSelect
@ -13,34 +14,25 @@ import { singularize } from 'ember-inflector';
* @param id {String} - The name of the form field
* @param models {String} - An array of model types to fetch from the API.
* @param onChange {Func} - The onchange action for this form field.
* @param inputValue {String} - A comma-separated string or an array of strings.
* @param inputValue {String | Array} - A comma-separated string or an array of strings.
* @param [helpText] {String} - Text to be displayed in the info tooltip for this form field
* @param label {String} - Label for this form field
* @param fallbackComponent {String} - name of component to be rendered if the API call 403s
*
* @param options {Array} - *Advanced usage* - `options` can be passed directly from the outside to the
* power-select component. If doing this, `models` should not also be passed as that will overwrite the
* passed value.
* @param search {Func} - *Advanced usage* - Customizes how the power-select component searches for matches -
* see the power-select docs for more information.
*
*/
export default Component.extend({
layout,
'data-test-component': 'search-select',
classNames: ['field', 'search-select'],
store: service(),
/*
* @public
* @param Function
*
* Function called when any of the inputs change
* accepts a single param `value`
*
*/
onChange: () => {},
/*
* @public
* @param String | Array
* A comma-separated string or an array of strings.
* Defaults to an empty array.
*
*/
inputValue: computed(function() {
return [];
}),
@ -52,6 +44,16 @@ export default Component.extend({
this._super(...arguments);
this.set('selectedOptions', this.inputValue || []);
},
didRender() {
this._super(...arguments);
let { oldOptions, options, selectedOptions } = this;
let hasFormattedInput = typeof selectedOptions.firstObject !== 'string';
if (options && !oldOptions && !hasFormattedInput) {
// this is the first time they've been set, so we need to format them
this.formatOptions(options);
}
this.set('oldOptions', options);
},
formatOptions: function(options) {
options = options.toArray().map(option => {
option.searchText = `${option.name} ${option.id}`;
@ -68,11 +70,17 @@ export default Component.extend({
});
this.set('selectedOptions', formattedOptions);
if (this.options) {
options = this.options.concat(options);
options = this.options.concat(options).uniq();
}
this.set('options', options);
},
fetchOptions: task(function*() {
if (!this.models) {
if (this.options) {
this.formatOptions(this.options);
}
return;
}
for (let modelType of this.models) {
if (modelType.includes('identity')) {
this.set('shouldRenderName', true);
@ -128,7 +136,10 @@ export default Component.extend({
constructSuggestion(id) {
return `Add new ${singularize(this.label)}: ${id}`;
},
hideCreateOptionOnSameID(id) {
hideCreateOptionOnSameID(id, options) {
if (options && options.length && options.firstObject.groupName) {
return !options.some(group => group.options.findBy('id', id));
}
let existingOption = this.options && (this.options.findBy('id', id) || this.options.findBy('name', id));
return !existingOption;
},

View File

@ -7,7 +7,7 @@
helpText=helpText
}}
{{else}}
<label class="title is-4" data-test-field-label>
<label class="{{if labelClass labelClass 'title is-4'}}" data-test-field-label>
{{label}}
{{#if helpText}}
{{#info-tooltip}}{{helpText}}{{/info-tooltip}}
@ -15,6 +15,7 @@
</label>
{{#power-select-with-create
options=options
search=search
onchange=(action "selectOption")
oncreate=(action "createOption")
placeholderComponent=(component "search-select-placeholder")

View File

@ -0,0 +1 @@
export { default } from 'core/components/search-select-placeholder';

View File

@ -0,0 +1 @@
export { default } from 'core/components/search-select';

View File

@ -18,6 +18,7 @@
"ember-composable-helpers": "*",
"ember-concurrency": "*",
"ember-maybe-in-element": "*",
"ember-power-select-with-create": "*",
"ember-radio-button": "*",
"ember-router-helpers": "*",
"ember-svg-jar": "*",

View File

@ -3,10 +3,12 @@
## Icon
`Icon` components are glyphs used to indicate important information.
**Params**
| Param | Type | Default | Description |
| --- | --- | --- | --- |
| glyph | <code>String</code> | <code></code> | The name of the SVG to render inline. |
| [size] | <code>String</code> | <code>&#x27;m&#x27;</code> | The size of the Icon, can be one of 's', 'm', 'l', 'xlm', 'xl', 'xxl'. The default is 'm'. |
**Example**

View File

@ -1,18 +1,21 @@
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in app/components/search-select.js. To make changes, first edit that file and run "yarn gen-story-md search-select" to re-generate the content.-->
<!--THIS FILE IS AUTO GENERATED. This file is generated from JSDoc comments in lib/core/addon/components/search-select.js. To make changes, first edit that file and run "yarn gen-story-md search-select" to re-generate the content.-->
## SearchSelect
The `SearchSelect` is an implementation of the [ember-power-select-with-create](https://github.com/poteto/ember-cli-flash) used for form elements where options come dynamically from the API.
**Params**
| Param | Type | Description |
| --- | --- | --- |
| id | <code>String</code> | The name of the form field |
| models | <code>String</code> | An array of model types to fetch from the API. |
| onChange | <code>Func</code> | The onchange action for this form field. |
| inputValue | <code>String</code> | A comma-separated string or an array of strings. |
| inputValue | <code>String</code> \| <code>Array</code> | A comma-separated string or an array of strings. |
| [helpText] | <code>String</code> | Text to be displayed in the info tooltip for this form field |
| label | <code>String</code> | Label for this form field |
| fallbackComponent | <code>String</code> | name of component to be rendered if the API call 403s |
| options | <code>Array</code> | *Advanced usage* - `options` can be passed directly from the outside to the power-select component. If doing this, `models` should not also be passed as that will overwrite the passed value. |
| search | <code>Func</code> | *Advanced usage* - Customizes how the power-select component searches for matches - see the power-select docs for more information. |
**Example**
@ -23,6 +26,6 @@ The `SearchSelect` is an implementation of the [ember-power-select-with-create](
**See**
- [Uses of SearchSelect](https://github.com/hashicorp/vault/search?l=Handlebars&q=SearchSelect+OR+search-select)
- [SearchSelect Source Code](https://github.com/hashicorp/vault/blob/master/ui/app/components/search-select.js)
- [SearchSelect Source Code](https://github.com/hashicorp/vault/blob/master/ui/lib/core/addon/components/search-select.js)
---

View File

@ -0,0 +1,41 @@
import hbs from 'htmlbars-inline-precompile';
import { storiesOf } from '@storybook/ember';
import notes from './search-select.md';
import { withKnobs, text } from '@storybook/addon-knobs';
const onChange = value => alert(`New value is "${value}"`);
const models = ['identity/groups'];
storiesOf('SearchSelect/', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(withKnobs({ escapeHTML: false }))
.add(
`SearchSelect`,
() => ({
template: hbs`
<h5 class="title is-5">Search Select</h5>
<SearchSelect
@id="groups"
@models={{models}}
@onChange={{onChange}}
@inputValue={{inputValue}}
@label={{label}}
@fallbackComponent="string-list"
@staticOptions={{staticOptions}}/>
`,
context: {
label: text('Label', 'Group IDs'),
helpText: text('Help Tooltip Text', 'Group IDs to associate with this entity'),
inputValue: [],
models: models,
onChange: onChange,
staticOptions: [
{ name: 'my-group', id: '123dsafdsarf' },
{ name: 'my-other-group', id: '45ssadd435' },
{ name: 'example-1', id: '5678' },
{ name: 'group-2', id: 'gro09283' },
],
},
}),
{ notes }
);

View File

@ -1,27 +0,0 @@
import Component from '@ember/component';
import { set, get, computed } from '@ember/object';
export default Component.extend({
config: null,
mounts: null,
// singleton mounts are not eligible for per-mount-filtering
singletonMountTypes: computed(function() {
return ['cubbyhole', 'system', 'token', 'identity', 'ns_system', 'ns_identity'];
}),
actions: {
addOrRemovePath(path, e) {
let config = get(this, 'config') || [];
let paths = get(config, 'paths').slice();
if (e.target.checked) {
paths.addObject(path);
} else {
paths.removeObject(path);
}
set(config, 'paths', paths);
},
},
});

View File

@ -0,0 +1,138 @@
import Component from '@ember/component';
import { set, computed } from '@ember/object';
import { inject as service } from '@ember/service';
import { readOnly } from '@ember/object/computed';
import { task, timeout } from 'ember-concurrency';
export default Component.extend({
'data-test-component': 'path-filter-config',
namespace: service(),
store: service(),
config: null,
namespaces: readOnly('namespace.accessibleNamespaces'),
lastOptions: null,
autoCompleteOptions: null,
namespacesFetched: null,
startedWithMode: false,
init() {
this._super(...arguments);
this.setAutoCompleteOptions.perform();
if (this.config.mode) {
this.set('startedWithMode', true);
}
},
fetchMountsForNamespace: task(function*(ns) {
let adapter = this.store.adapterFor('application');
let secret = [];
let auth = [];
let mounts = ns
? yield adapter.ajax('/v1/sys/internal/ui/mounts', 'GET', { namespace: ns })
: yield adapter.ajax('/v1/sys/internal/ui/mounts', 'GET');
['secret', 'auth'].forEach(key => {
for (let [id, info] of Object.entries(mounts.data[key])) {
let longId;
if (key === 'auth') {
longId = ns ? `${ns}/auth/${id}` : `auth/${id}`;
} else {
longId = ns ? `${ns}/${id}` : id;
}
info.path = longId;
// don't add singleton mounts
if (!this.singletonMountTypes.includes(info.type)) {
(key === 'secret' ? secret : auth).push({
id: longId,
name: longId,
searchText: `${longId} ${info.type} ${info.accessor}`,
});
}
}
});
return {
secret,
auth,
};
}),
filterOptions(list, term) {
let paths = this.config.paths;
return list
.map(({ groupName, options }) => {
let trimmedOptions = options.filter(op => {
if (term) {
return op.searchText.includes(term) && !paths.includes(op.id);
}
return !paths.includes(op.id);
});
return trimmedOptions.length ? { groupName, options: trimmedOptions } : null;
})
.compact();
},
setAutoCompleteOptions: task(function*(term) {
let { namespaces, lastOptions } = this;
let namespaceToFetch = namespaces.find(ns => ns === term);
let secretList = [];
let authList = [];
let options = [];
if (term) {
yield timeout(200);
}
if (!term || (term && namespaceToFetch)) {
// fetch auth and secret methods from sys/internal/ui/mounts for the given namespace
let result = yield this.fetchMountsForNamespace.perform(namespaceToFetch);
secretList = result.secret;
authList = result.auth;
}
var currentSecrets = lastOptions && lastOptions.findBy('groupName', 'Secret Engines');
var currentAuths = lastOptions && lastOptions.findBy('groupName', 'Auth Methods');
let formattedNamespaces = namespaces.map(val => {
return {
id: val,
name: val,
searchText: val,
};
});
options.push({ groupName: 'Namespaces', options: formattedNamespaces });
let secretOptions = currentSecrets ? [...currentSecrets.options, ...secretList] : secretList;
options.push({ groupName: 'Secret Engines', options: secretOptions.uniqBy('id') });
let authOptions = currentAuths ? [...currentAuths.options, ...authList] : authList;
options.push({ groupName: 'Auth Methods', options: authOptions.uniqBy('id') });
let filtered = term ? this.filterOptions(options, term) : this.filterOptions(options);
if (!term) {
this.set('autoCompleteOptions', filtered);
}
this.set('lastOptions', filtered);
return filtered;
}),
// singleton mounts are not eligible for per-mount-filtering
singletonMountTypes: computed(function() {
return ['cubbyhole', 'system', 'token', 'identity', 'ns_system', 'ns_identity', 'ns_token'];
}),
willDestroyElement() {
this._super(...arguments);
},
actions: {
pathsChanged(paths) {
// set paths on the model
set(this.config, 'paths', paths);
if (paths.length) {
// remove the selected item from the default list of options
let filtered = this.filterOptions(this.autoCompleteOptions);
this.set('autoCompleteOptions', filtered);
} else {
// if there's no paths, we need to re-fetch like on init
this.setAutoCompleteOptions.perform();
}
},
},
});

View File

@ -2,22 +2,23 @@ import { isPresent } from '@ember/utils';
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import Controller from '@ember/controller';
import { copy } from 'ember-copy';
import { resolve } from 'rsvp';
const DEFAULTS = {
token: null,
id: null,
loading: false,
errors: [],
showFilterConfig: false,
primary_api_addr: null,
primary_cluster_addr: null,
filterConfig: {
mode: 'whitelist',
mode: null,
paths: [],
},
};
export default Controller.extend(DEFAULTS, {
export default Controller.extend(copy(DEFAULTS, true), {
store: service(),
rm: service('replication-mode'),
replicationMode: alias('rm.mode'),
@ -34,12 +35,16 @@ export default Controller.extend(DEFAULTS, {
const config = this.get('filterConfig');
const id = this.get('id');
config.id = id;
const configRecord = this.get('store').createRecord('mount-filter-config', config);
// if there is no mode, then they don't want to filter, so we don't save a filter config
if (!config.mode) {
return resolve();
}
const configRecord = this.get('store').createRecord('path-filter-config', config);
return configRecord.save().catch(e => this.submitError(e));
},
reset() {
this.setProperties(DEFAULTS);
this.setProperties(copy(DEFAULTS, true));
},
submitSuccess(resp, action) {
@ -66,14 +71,9 @@ export default Controller.extend(DEFAULTS, {
submitHandler(action, clusterMode, data, event) {
const replicationMode = this.get('replicationMode');
let saveFilterConfig;
if (event && event.preventDefault) {
event.preventDefault();
}
if (data && isPresent(data.saveFilterConfig)) {
saveFilterConfig = data.saveFilterConfig;
delete data.saveFilterConfig;
}
this.setProperties({
loading: true,
errors: [],
@ -93,13 +93,9 @@ export default Controller.extend(DEFAULTS, {
.replicationAction(action, replicationMode, clusterMode, data)
.then(
resp => {
if (saveFilterConfig) {
return this.saveFilterConfig().then(() => {
return this.submitSuccess(resp, action, clusterMode);
});
} else {
return this.submitSuccess(resp, action, clusterMode);
}
},
(...args) => this.submitError(...args)
);

View File

@ -2,11 +2,6 @@ import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import Controller from '@ember/controller';
const CONFIG_DEFAULTS = {
mode: 'whitelist',
paths: [],
};
export default Controller.extend({
flashMessages: service(),
rm: service('replication-mode'),
@ -14,13 +9,18 @@ export default Controller.extend({
actions: {
resetConfig(config) {
if (config.get('isNew')) {
config.setProperties(CONFIG_DEFAULTS);
config.setProperties({
mode: null,
paths: [],
});
} else {
config.rollbackAttributes();
}
},
saveConfig(config, isDelete) {
saveConfig(config) {
// if the mode is null, we want no filtering, so we should delete any existing config
let isDelete = config.mode === null;
const flash = this.get('flashMessages');
const id = config.id;
const redirectArgs = isDelete

View File

@ -1,26 +1,17 @@
import { hash } from 'rsvp';
import Base from '../../replication-base';
export default Base.extend({
model() {
return hash({
cluster: this.modelFor('mode.secondaries'),
mounts: this.fetchMounts(),
});
return this.modelFor('mode.secondaries');
},
redirect(model) {
const replicationMode = this.paramsFor('mode').replication_mode;
if (!model.cluster.get(`${replicationMode}.isPrimary`) || !model.cluster.get('canAddSecondary')) {
if (!model.get(`${replicationMode}.isPrimary`) || !model.get('canAddSecondary')) {
return this.transitionTo('mode', replicationMode);
}
},
setupController(controller, model) {
controller.set('model', model.cluster);
controller.set('mounts', model.mounts);
},
resetController(controller) {
controller.reset();
},

View File

@ -10,21 +10,23 @@ export default Base.extend({
findOrCreate(id) {
const flash = this.get('flashMessages');
return this.store
.findRecord('mount-filter-config', id)
.findRecord('path-filter-config', id)
.then(() => {
// if we find a record, transition to the edit view
return this.transitionTo('mode.secondaries.config-edit', id)
.followRedirects()
.then(() => {
flash.info(
`${id} already had a mount filter config, so we loaded the config edit screen for you.`
`${id} already had a path filter config, so we loaded the config edit screen for you.`
);
});
})
.catch(e => {
if (e.httpStatus === 404) {
return this.store.createRecord('mount-filter-config', {
return this.store.createRecord('path-filter-config', {
id,
mode: null,
paths: [],
});
} else {
throw e;
@ -49,7 +51,6 @@ export default Base.extend({
return hash({
cluster: this.modelFor('mode'),
config: this.findOrCreate(params.secondary_id),
mounts: this.fetchMounts(),
});
},
});

View File

@ -7,8 +7,7 @@ export default Base.extend({
model(params) {
return hash({
cluster: this.modelFor('mode.secondaries'),
config: this.store.findRecord('mount-filter-config', params.secondary_id),
mounts: this.fetchMounts(),
config: this.store.findRecord('path-filter-config', params.secondary_id),
});
},

View File

@ -8,7 +8,7 @@ export default Base.extend({
const id = params.secondary_id;
return hash({
cluster: this.modelFor('application'),
config: this.store.findRecord('mount-filter-config', id).catch(e => {
config: this.store.findRecord('path-filter-config', id).catch(e => {
if (e.httpStatus === 404) {
// return an empty obj to let them nav to create
return resolve({ id });

View File

@ -1,6 +1,5 @@
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { hash, resolve } from 'rsvp';
import Route from '@ember/routing/route';
import UnloadModelRouteMixin from 'vault/mixins/unload-model-route';
@ -9,14 +8,5 @@ export default Route.extend(UnloadModelRouteMixin, {
version: service(),
rm: service('replication-mode'),
modelPath: 'model.config',
fetchMounts() {
return hash({
mounts: this.store.findAll('secret-engine'),
auth: this.store.findAll('auth-method'),
}).then(({ mounts, auth }) => {
return resolve(mounts.toArray().concat(auth.toArray()));
});
},
replicationMode: alias('rm.mode'),
});

View File

@ -1,79 +0,0 @@
<label for="filter-mode" class="is-label">
Mount filter mode
</label>
<div class="field is-expanded">
<div class="select is-fullwidth">
<select
id="filter-mode"
onchange={{action (mut config.mode) value="target.value"}}
data-test-replication-filter-mount-mode=true
>
{{#each (array "whitelist" "blacklist") as |mode|}}
<option selected={{eq config.mode mode}} value="{{mode}}">
{{capitalize mode}}
</option>
{{/each}}
</select>
</div>
<p class="help has-text-grey">
{{#if (eq config.mode "blacklist")}}
Selected mounts will be excluded when replicating to the secondary <code>{{or id config.id}}</code>.
{{else}}
<em>Only</em> the selected mounts will be replicated to the secondary <code>{{or id config.id}}</code>.
{{/if}}
</p>
</div>
<label class="is-label">
Filtered Mounts
</label>
<div class="columns is-mobile is-gapless is-marginless table">
<div class="column is-narrow thead">
<div class="th"></div>
</div>
<div class="column is-5 thead">
<div class="th">
Mount Path
</div>
</div>
<div class="column thead">
<div class="th">
Mount Type
</div>
</div>
</div>
{{#each mounts as |mount|}}
{{#unless (or mount.local (contains mount.type singletonMountTypes))}}
{{!-- auth mounts have apiPath, secret mounts use path --}}
{{#with (or mount.apiPath mount.path) as |path| }}
<label for="filter-{{mount.accessor}}" class="columns is-mobile is-gapless is-marginless table">
<div class="column is-narrow td">
<div class="td is-borderless">
<div class="field">
<div class="b-checkbox no-label">
<input
id="filter-{{mount.accessor}}"
type="checkbox"
class="styled"
checked={{contains config.paths path}}
onChange={{action 'addOrRemovePath' path}}
data-test-mount-filter="{{mount.type}}"
/>
<label for="filter-{{mount.accessor}}" class="is-label"></label>
</div>
</div>
</div>
</div>
<code class="column is-5 td">
<div class="td is-borderless" data-test-mount-filter-path-for-type="{{mount.type}}">
{{path}}
</div>
</code>
<div class="column td">
<div class="td is-borderless">
{{mount.type}}
</div>
</div>
</label>
{{/with}}
{{/unless}}
{{/each}}

View File

@ -0,0 +1,126 @@
<h4 class="title is-5">
Filtered paths
</h4>
<div class="radio-card-selector">
<label
for="no-filtering"
class="radio-card
{{if (not config.mode) " is-selected"}}"
>
<div class="radio-card-row">
<Icon
@glyph="file-fill"
@size="xlm"
class="radio-card-icon"
/>
<div class="radio-card-message">
<h5 class="radio-card-message-title">
Include everything
</h5>
<p class="radio-card-message-body">
All namespaces and mounts in this cluster will be replicated
</p>
</div>
</div>
<div class="radio-card-radio-row">
<RadioButton
@value={{null}}
@radioClass="radio"
@groupValue={{config.mode}}
@changed={{queue
(action (mut config.mode))
}}
@name="config-mode"
@radioId="no-filtering"
/>
<label for="no-filtering"></label>
</div>
</label>
<label
for="allow"
class="radio-card
{{if (eq config.mode "allow") " is-selected"}}"
>
<div class="radio-card-row">
<Icon
@glyph="file-success"
@size="xlm"
class="radio-card-icon"
/>
<div class="radio-card-message">
<h5 class="radio-card-message-title">
Allow
</h5>
<p class="radio-card-message-body">
Only include the selected namespaces and mounts
</p>
</div>
</div>
<div class="radio-card-radio-row">
<RadioButton
@value="allow"
@radioClass="radio"
@groupValue={{config.mode}}
@changed={{queue
(action (mut config.mode))
}}
@name="config-mode"
@radioId="allow"
/>
<label for="allow"></label>
</div>
</label>
<label
for="deny"
class="radio-card
{{if (eq config.mode "deny") " is-selected"}}"
>
<div class="radio-card-row">
<Icon
@glyph="file-error"
@size="xlm"
class="radio-card-icon"
/>
<div class="radio-card-message">
<h5 class="radio-card-message-title">
Deny
</h5>
<p class="radio-card-message-body">
Do not include the selected namespaces and mounts
</p>
</div>
</div>
<div class="radio-card-radio-row">
<RadioButton
@value="deny"
@radioClass="radio"
@groupValue={{config.mode}}
@changed={{queue
(action (mut config.mode))
}}
@name="config-mode"
@radioId="deny"
/>
<label for="deny"></label>
</div>
</label>
</div>
{{#if (or (eq config.mode "allow") (eq config.mode "deny"))}}
<SearchSelect
@id="paths"
@labelClass="title is-7"
@onChange={{action "pathsChanged"}}
@inputValue={{config.paths}}
@label="Paths in this {{config.mode}}"
@options={{this.autoCompleteOptions}}
@search={{perform this.setAutoCompleteOptions}}
/>
{{/if}}
{{#if (and (not config.mode) this.startedWithMode)}}
<AlertInline
data-test-remove-warning
@type="warning"
@message="Saving with 'Include everything' will remove any existing filter configuration"
/>
{{/if}}

View File

@ -1,4 +1,4 @@
<form {{action "onSubmit" "secondary-token" "primary" (hash ttl=ttl id=id saveFilterConfig=showFilterConfig) on="submit"}}>
<form {{action "onSubmit" "secondary-token" "primary" (hash ttl=ttl id=id) on="submit"}}>
<div class="box is-fullwidth is-shadowless is-marginless">
<h4 class="title is-5">
Generate a secondary token
@ -51,24 +51,11 @@
</p>
</div>
{{#if (eq replicationMode "performance")}}
<div class="field">
{{toggle-button
toggleTarget=this
toggleAttr='showFilterConfig'
closedLabel='Configure performance mount filtering'
openLabel='Hide performance mount filtering config'
data-test-replication-secondary-token-options=true
}}
{{#if showFilterConfig}}
<div class="box">
{{mount-filter-config-list
config=filterConfig
mounts=mounts
id=id
}}
</div>
{{/if}}
</div>
<PathFilterConfigList
@paths={{paths}}
@config={{filterConfig}}
@id={{id}}
/>
{{/if}}
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">

View File

@ -1,11 +1,11 @@
<div class="box is-fullwidth is-shadowless is-marginless">
<h4 class="title is-5 is-marginless">
Create a mount filter config for <code>{{model.config.id}}</code>
Create a path filter config for <code>{{model.config.id}}</code>
</h4>
</div>
<form {{action "saveConfig" model.config on="submit"}}>
{{mount-filter-config-list
mounts=model.mounts
{{path-filter-config-list
paths=model.paths
config=model.config
}}
<div class="field is-grouped box is-fullwidth is-shadowless">

View File

@ -1,31 +1,19 @@
<Toolbar>
<ToolbarActions>
<ConfirmAction
@buttonClasses="toolbar-link"
@confirmMessage="This will affect which data gets replicated to this secondary."
@onConfirmAction={{action "saveConfig" model.config true}}
data-test-delete-mount-config="true"
>
Delete config
</ConfirmAction>
</ToolbarActions>
</Toolbar>
<Toolbar/>
<div class="box is-fullwidth is-shadowless is-marginless">
<h4 class="title is-5 is-marginless">
Edit mount filter config for <code>{{model.config.id}}</code>
Edit path filter config for <code>{{model.config.id}}</code>
</h4>
</div>
<form {{action "saveConfig" model.config on="submit"}}>
{{mount-filter-config-list
mounts=model.mounts
config=model.config
}}
<PathFilterConfigList
@paths={{model.paths}}
@config={{model.config}}
/>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="field is-grouped">
<div class="control">
<button type="submit" class="button is-primary" >
Save
Update
</button>
</div>
<div class="control">

3
ui/public/file-error.svg Normal file
View File

@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20,14 L20,20.5714286 C20,21.3571429 19.2857143,22 18.5,22 L4.5,22 C3.71428571,22 3,21.2857143 3,20.5 L3,3.5 C3,2.71428571 3.71428571,2 4.5,2 L10,2 L10,3.5 L4.5,3.5 L4.5,20.5 L18.5,20.5 L18.5,14 L20,14 Z M7,18.5 L16,18.5 L16,17 L7,17 L7,18.5 Z M7,15.5 L16,15.5 L16,14 L7,14 L7,15.5 Z M7,12.5 L11,12.5 L11,11 L7,11 L7,12.5 Z M7,9.5 L10,9.5 L10,8 L7,8 L7,9.5 Z M20,1 C21.6568542,1 23,2.34314575 23,4 L23,9 C23,10.6568542 21.6568542,12 20,12 L15,12 C13.3431458,12 12,10.6568542 12,9 L12,4 C12,2.34314575 13.3431458,1 15,1 L20,1 Z M19.9425,3 L17.5,5.4425 L15.0575,3 L14,4.0575 L16.4425,6.5 L14,8.9425 L15.0575,10 L17.5,7.5575 L19.9425,10 L21,8.9425 L18.5575,6.5 L21,4.0575 L19.9425,3 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 785 B

View File

@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20,14 L20,20.5714286 C20,21.3571429 19.2857143,22 18.5,22 L4.5,22 C3.71428571,22 3,21.2857143 3,20.5 L3,3.5 C3,2.71428571 3.71428571,2 4.5,2 L11,2 L10,3.5 L4.5,3.5 L4.5,20.5 L18.5,20.5 L18.5,14 L20,14 Z M7,18.5 L16,18.5 L16,17 L7,17 L7,18.5 Z M7,15.5 L16,15.5 L16,14 L7,14 L7,15.5 Z M7,12.5 L12,12.5 L12,11 L7,11 L7,12.5 Z M7,9.5 L10,9.5 L10,8 L7,8 L7,9.5 Z M17.5,12 C20.5375661,12 23,9.53756612 23,6.5 C23,3.46243388 20.5375661,1 17.5,1 C14.4624339,1 12,3.46243388 12,6.5 C12,9.53756612 14.4624339,12 17.5,12 Z M14,6.66152019 L15.0254545,5.6567696 L16.9090909,7.49524941 L20.4745455,4 L21.5,5.01187648 L16.9090909,9.51187648 L14,6.66152019 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 747 B

View File

@ -1,34 +0,0 @@
import hbs from 'htmlbars-inline-precompile';
import { storiesOf } from '@storybook/ember';
import notes from './search-select.md';
import { withKnobs, text, select } from '@storybook/addon-knobs';
const onChange = (value) => alert(`New value is "${value}"`);
const models = ["identity/groups"];
storiesOf('SearchSelect/', module)
.addParameters({ options: { showPanel: true } })
.addDecorator(withKnobs({ escapeHTML: false }))
.add(`SearchSelect`, () => ({
template: hbs`
<h5 class="title is-5">Search Select</h5>
<SearchSelect
@id="groups"
@models={{models}}
@onChange={{onChange}}
@inputValue={{inputValue}}
@label={{label}}
@fallbackComponent="string-list"
@staticOptions={{staticOptions}}/>
`,
context: {
label: text("Label", "Group IDs"),
helpText: text("Help Tooltip Text", "Group IDs to associate with this entity"),
inputValue: [],
models: models,
onChange: onChange,
staticOptions: [{ name: "my-group", id: "123dsafdsarf" }, { name: "my-other-group", id: "45ssadd435" }, { name: "example-1", id: "5678" }, { name: "group-2", id: "gro09283" }],
},
}),
{ notes }
);

View File

@ -1,38 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, findAll, fillIn, blur } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import engineResolverFor from 'ember-engines/test-support/engine-resolver-for';
const resolver = engineResolverFor('replication');
module('Integration | Component | mount filter config list', function(hooks) {
setupRenderingTest(hooks, { resolver });
test('it renders', async function(assert) {
this.set('config', { mode: 'whitelist', paths: [] });
this.set('mounts', [{ path: 'userpass/', type: 'userpass', accessor: 'userpass' }]);
await render(hbs`{{mount-filter-config-list config=config mounts=mounts}}`);
assert.equal(findAll('#filter-userpass').length, 1);
});
test('it sets config.paths', async function(assert) {
this.set('config', { mode: 'whitelist', paths: [] });
this.set('mounts', [{ path: 'userpass/', type: 'userpass', accessor: 'userpass' }]);
await render(hbs`{{mount-filter-config-list config=config mounts=mounts}}`);
await click('#filter-userpass');
assert.ok(this.get('config.paths').includes('userpass/'), 'adds to paths');
await click('#filter-userpass');
assert.equal(this.get('config.paths').length, 0, 'removes from paths');
});
test('it sets config.mode', async function(assert) {
this.set('config', { mode: 'whitelist', paths: [] });
await render(hbs`{{mount-filter-config-list config=config}}`);
await fillIn('#filter-mode', 'blacklist');
await blur('#filter-mode');
assert.equal(this.get('config.mode'), 'blacklist');
});
});

View File

@ -0,0 +1,116 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import { typeInSearch, clickTrigger } from 'ember-power-select/test-support/helpers';
import hbs from 'htmlbars-inline-precompile';
import engineResolverFor from 'ember-engines/test-support/engine-resolver-for';
import Service from '@ember/service';
import sinon from 'sinon';
import { Promise } from 'rsvp';
import { create } from 'ember-cli-page-object';
import ss from 'vault/tests/pages/components/search-select';
const searchSelect = create(ss);
const resolver = engineResolverFor('replication');
const MOUNTS_RESPONSE = {
data: {
secret: {},
auth: {
'userpass/': { type: 'userpass', accessor: 'userpass' },
},
},
};
const NAMESPACE_MOUNTS_RESPONSE = {
data: {
secret: {
'namespace-kv/': { type: 'kv', accessor: 'kv' },
},
auth: {},
},
};
module('Integration | Component | path filter config list', function(hooks) {
setupRenderingTest(hooks, { resolver });
hooks.beforeEach(function() {
let ajaxStub = sinon.stub().usingPromise(Promise);
ajaxStub.withArgs('/v1/sys/internal/ui/mounts', 'GET').resolves(MOUNTS_RESPONSE);
ajaxStub
.withArgs('/v1/sys/internal/ui/mounts', 'GET', { namespace: 'ns1' })
.resolves(NAMESPACE_MOUNTS_RESPONSE);
this.set('ajaxStub', ajaxStub);
const namespaceServiceStub = Service.extend({
init() {
this._super(...arguments);
this.set('accessibleNamespaces', ['ns1']);
},
});
const storeServiceStub = Service.extend({
adapterFor() {
return {
ajax: ajaxStub,
};
},
});
this.owner.register('service:namespace', namespaceServiceStub);
this.owner.register('service:store', storeServiceStub);
});
test('it renders', async function(assert) {
this.set('config', { mode: null, paths: [] });
await render(hbs`<PathFilterConfigList @config={{config}} @paths={{paths}} />`);
assert.dom('[data-test-component=path-filter-config]').exists();
});
test('it sets config.paths', async function(assert) {
this.set('config', { mode: 'allow', paths: [] });
this.set('paths', []);
await render(hbs`<PathFilterConfigList @config={{config}} @paths={{paths}} />`);
await clickTrigger();
await typeInSearch('auth');
await searchSelect.options.objectAt(1).click();
assert.ok(this.config.paths.includes('auth/userpass/'), 'adds to paths');
await clickTrigger();
await assert.equal(searchSelect.options.length, 1, 'has one option left');
await searchSelect.deleteButtons.objectAt(0).click();
assert.equal(this.config.paths.length, 0, 'removes from paths');
await clickTrigger();
await assert.equal(searchSelect.options.length, 2, 'has both options');
});
test('it sets config.mode', async function(assert) {
this.set('config', { mode: 'allow', paths: [] });
await render(hbs`<PathFilterConfigList @config={{this.config}} />`);
await click('#deny');
assert.equal(this.config.mode, 'deny');
await click('#no-filtering');
assert.equal(this.config.mode, null);
});
test('it shows a warning when going from a mode to allow all', async function(assert) {
this.set('config', { mode: 'allow', paths: [] });
await render(hbs`<PathFilterConfigList @config={{this.config}} />`);
await click('#no-filtering');
assert.dom('[data-test-remove-warning]').exists('shows removal warning');
});
test('it fetches mounts from a namespace when namespace name is entered', async function(assert) {
this.set('config', { mode: 'allow', paths: [] });
this.set('paths', []);
await render(hbs`<PathFilterConfigList @config={{config}} @paths={{paths}} />`);
await clickTrigger();
assert.equal(searchSelect.options.length, 2, 'shows userpass and namespace as an option');
// type the namespace to trigger an ajax request
await typeInSearch('ns1');
assert.equal(searchSelect.options.length, 2, 'has ns and ns mount in the list');
await searchSelect.options.objectAt(1).click();
assert.ok(this.config.paths.includes('ns1/namespace-kv/'), 'adds namespace mount to paths');
});
});