ui: Intention Custom Resource Banners (#9018)

This commit is contained in:
John Cowen 2020-10-26 09:30:07 +00:00 committed by GitHub
parent c7c5aaa609
commit 37e6047aa8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 587 additions and 382 deletions

View File

@ -1,14 +1,14 @@
<DataForm
@dc={{dc}}
@nspace={{nspace}}
@type="intention"
@autofill={{autofill}}
@item={{item}}
@src={{src}}
@onchange={{action "change"}}
@onsubmit={{action onsubmit}}
as |api|
>
@dc={{@dc}}
@nspace={{@nspace}}
@autofill={{@autofill}}
@item={{@item}}
@src={{@src}}
@onchange={{action this.change}}
@onsubmit={{action this.onsubmit}}
as |api|>
<BlockSlot @name="error" as |Notification|>
<Notification>
<p data-notification role="alert" class="error notification-update">
@ -27,43 +27,69 @@
</BlockSlot>
<BlockSlot @name="form">
{{#let api.data as |item|}}
{{#if item.IsEditable}}
<DataSource
@src={{concat '/' nspace '/' dc '/services'}}
@onchange={{action "createServices" item}}
@src={{concat '/' @nspace '/' @dc '/services'}}
@onchange={{action this.createServices item}}
/>
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
<DataSource
@src="/*/*/namespaces"
@onchange={{action "createNspaces" item}}
@onchange={{action this.createNspaces item}}
/>
{{/if}}
{{/if}}
{{#if (and api.isCreate this.isManagedByCRDs)}}
<Consul::Intention::Notice::CustomResource @type="warning" />
{{/if}}
<form onsubmit={{action api.submit}}>
<Consul::Intention::Form::Fieldsets
@nspaces={{nspaces}}
@services={{services}}
@SourceName={{SourceName}}
@SourceNS={{SourceNS}}
@DestinationName={{DestinationName}}
@DestinationNS={{DestinationNS}}
@nspaces={{this.nspaces}}
@services={{this.services}}
@SourceName={{this.SourceName}}
@SourceNS={{this.SourceNS}}
@DestinationName={{this.DestinationName}}
@DestinationNS={{this.DestinationNS}}
@item={{item}}
@disabled={{api.disabled}}
@create={{api.isCreate}}
@onchange={{api.change}}
/>
<div>
<button type="submit" disabled={{or item.isInvalid api.disabled}}>Save</button>
<button type="reset" onclick={{action oncancel item}} disabled={{api.disabled}}>Cancel</button>
<button
type="submit"
disabled={{or item.isInvalid api.disabled}}
>
Save
</button>
<button
type="reset"
disabled={{api.disabled}}
{{on 'click' (fn this.oncancel item)}}
>
Cancel
</button>
{{#if (not api.isCreate)}}
{{#if (not-eq item.ID 'anonymous') }}
<ConfirmationDialog @message="Are you sure you want to delete this Intention?">
<BlockSlot @name="action" as |confirm|>
<button data-test-delete type="button" class="type-delete" {{action confirm api.delete}} disabled={{api.disabled}}>Delete</button>
<button
data-test-delete
type="button"
class="type-delete"
disabled={{api.disabled}}
{{on 'click' (fn confirm api.delete)}}
>
Delete
</button>
</BlockSlot>
<BlockSlot @name="dialog" as |execute cancel message|>
<DeleteConfirmation @message={{message}} @execute={{execute}} @cancel={{cancel}} />
<DeleteConfirmation
@message={{message}}
@execute={{execute}}
@cancel={{cancel}}
/>
</BlockSlot>
</ConfirmationDialog>
{{/if}}
@ -71,10 +97,31 @@
</div>
</form>
{{else}}
<Consul::Intention::View
@item={{item}}
/>
{{#if item.IsManagedByCRD}}
<Notice
class="crd"
@type="warning"
as |notice|>
<notice.Header>
<h3>
Intention Custom Resource
</h3>
</notice.Header>
<notice.Body>
<p>
This Intention is view only because it is managed through an Intention Custom Resource in your Kubernetes cluster.
</p>
<p>
<a href="{{env 'CONSUL_DOCS_URL'}}/k8s/crds" target="_blank" rel="noopener noreferrer">Learn more about CRDs</a>
</p>
</notice.Body>
</Notice>
{{/if}}
<Consul::Intention::View
@item={{item}}
/>
{{/if}}
{{/let}}
</BlockSlot>
</DataForm>

View File

@ -1,112 +1,150 @@
import Component from '@ember/component';
import { setProperties, set, get } from '@ember/object';
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default Component.extend({
tagName: '',
ondelete: function() {
this.onsubmit(...arguments);
},
oncancel: function() {
this.onsubmit(...arguments);
},
onsubmit: function() {},
actions: {
createServices: function(item, e) {
// Services in the menus should:
// 1. Be unique (they potentially could be duplicated due to services from different namespaces)
// 2. Only include services that shold have intentions
// 3. Include an 'All Services' option
// 4. Include the current Source and Destination incase they are virtual services/don't exist yet
let items = e.data
.uniqBy('Name')
.toArray()
.filter(
item => !['connect-proxy', 'mesh-gateway', 'terminating-gateway'].includes(item.Kind)
)
.sort((a, b) => a.Name.localeCompare(b.Name));
items = [{ Name: '*' }].concat(items);
let source = items.findBy('Name', item.SourceName);
if (!source) {
source = { Name: item.SourceName };
items = [source].concat(items);
}
let destination = items.findBy('Name', item.DestinationName);
if (!destination) {
destination = { Name: item.DestinationName };
items = [destination].concat(items);
}
setProperties(this, {
services: items,
SourceName: source,
DestinationName: destination,
});
},
createNspaces: function(item, e) {
// Nspaces in the menus should:
// 1. Include an 'All Namespaces' option
// 2. Include the current SourceNS and DestinationNS incase they don't exist yet
let items = e.data.toArray().sort((a, b) => a.Name.localeCompare(b.Name));
items = [{ Name: '*' }].concat(items);
let source = items.findBy('Name', item.SourceNS);
if (!source) {
source = { Name: item.SourceNS };
items = [source].concat(items);
}
let destination = items.findBy('Name', item.DestinationNS);
if (!destination) {
destination = { Name: item.DestinationNS };
items = [destination].concat(items);
}
setProperties(this, {
nspaces: items,
SourceNS: source,
DestinationNS: destination,
});
},
change: function(e, form, item) {
const target = e.target;
export default class ConsulIntentionForm extends Component {
let name, selected, match;
switch (target.name) {
case 'SourceName':
case 'DestinationName':
case 'SourceNS':
case 'DestinationNS':
name = selected = target.value;
// Names can be selected Service EmberObjects or typed in strings
// if its not a string, use the `Name` from the Service EmberObject
if (typeof name !== 'string') {
name = get(target.value, 'Name');
@tracked services;
@tracked SourceName;
@tracked DestinationName;
@tracked nspaces;
@tracked SourceNS;
@tracked DestinationNS;
@tracked isManagedByCRDs;
@service('repository/intention') repo;
constructor(owner, args) {
super(...arguments);
this.updateCRDManagement();
}
ondelete() {
if(this.args.ondelete) {
this.args.ondelete(...arguments);
} else {
this.onsubmit(...arguments);
}
}
oncancel() {
if(this.args.oncancel) {
this.args.oncancel(...arguments);
} else {
this.onsubmit(...arguments);
}
}
onsubmit() {
if(this.args.onsubmit) {
this.args.onsubmit(...arguments);
}
}
@action
updateCRDManagement() {
this.isManagedByCRDs = this.repo.isManagedByCRDs();
}
@action
createServices (item, e) {
// Services in the menus should:
// 1. Be unique (they potentially could be duplicated due to services from different namespaces)
// 2. Only include services that shold have intentions
// 3. Include an 'All Services' option
// 4. Include the current Source and Destination incase they are virtual services/don't exist yet
let items = e.data
.uniqBy('Name')
.toArray()
.filter(
item => !['connect-proxy', 'mesh-gateway', 'terminating-gateway'].includes(item.Kind)
)
.sort((a, b) => a.Name.localeCompare(b.Name));
items = [{ Name: '*' }].concat(items);
let source = items.findBy('Name', item.SourceName);
if (!source) {
source = { Name: item.SourceName };
items = [source].concat(items);
}
let destination = items.findBy('Name', item.DestinationName);
if (!destination) {
destination = { Name: item.DestinationName };
items = [destination].concat(items);
}
this.services = items;
this.SourceName = source;
this.DestinationName = destination;
}
@action
createNspaces (item, e) {
// Nspaces in the menus should:
// 1. Include an 'All Namespaces' option
// 2. Include the current SourceNS and DestinationNS incase they don't exist yet
let items = e.data.toArray().sort((a, b) => a.Name.localeCompare(b.Name));
items = [{ Name: '*' }].concat(items);
let source = items.findBy('Name', item.SourceNS);
if (!source) {
source = { Name: item.SourceNS };
items = [source].concat(items);
}
let destination = items.findBy('Name', item.DestinationNS);
if (!destination) {
destination = { Name: item.DestinationNS };
items = [destination].concat(items);
}
this.nspaces = items;
this.SourceNS = source;
this.DestinationNS = destination;
}
@action
change(e, form, item) {
const target = e.target;
let name, selected, match;
switch (target.name) {
case 'SourceName':
case 'DestinationName':
case 'SourceNS':
case 'DestinationNS':
name = selected = target.value;
// Names can be selected Service EmberObjects or typed in strings
// if its not a string, use the `Name` from the Service EmberObject
if (typeof name !== 'string') {
name = target.value.Name;
}
// mutate the value with the string name
// which will be handled by the form
target.value = name;
// these are 'non-form' variables so not on `item`
// these variables also exist in the template so we know
// the current selection
// basically the difference between
// `item.DestinationName` and just `DestinationName`
// see if the name is already in the list
match = this.services.filterBy('Name', name);
if (match.length === 0) {
// if its not make a new 'fake' Service that doesn't exist yet
// and add it to the possible services to make an intention between
selected = { Name: name };
switch (target.name) {
case 'SourceName':
case 'DestinationName':
this.services = [selected].concat(this.services.toArray());
break;
case 'SourceNS':
case 'DestinationNS':
this.nspaces = [selected].concat(this.nspaces.toArray());
break;
}
// mutate the value with the string name
// which will be handled by the form
target.value = name;
// these are 'non-form' variables so not on `item`
// these variables also exist in the template so we know
// the current selection
// basically the difference between
// `item.DestinationName` and just `DestinationName`
// see if the name is already in the list
match = this.services.filterBy('Name', name);
if (match.length === 0) {
// if its not make a new 'fake' Service that doesn't exist yet
// and add it to the possible services to make an intention between
selected = { Name: name };
switch (target.name) {
case 'SourceName':
case 'DestinationName':
set(this, 'services', [selected].concat(this.services.toArray()));
break;
case 'SourceNS':
case 'DestinationNS':
set(this, 'nspaces', [selected].concat(this.nspaces.toArray()));
break;
}
}
set(this, target.name, selected);
break;
}
form.handleEvent(e);
},
},
});
}
this[target.name] = selected;
break;
}
form.handleEvent(e);
}
}

View File

@ -1,6 +1,7 @@
<div
class="consul-intention-list"
...attributes
{{did-update this.updateCRDManagement @items}}
>
<DataWriter
@sink={{concat '/' @dc '/' @nspace '/intention/'}}
@ -10,16 +11,22 @@
<BlockSlot @name="content">
{{#let (hash
Check=(component 'consul/intention/list/check')
Table=(component 'consul/intention/list/table' delete=writer.delete items=@items)
Table=(component 'consul/intention/list/table' delete=writer.delete items=this.items)
CheckNotice=(if this.checkedItem
(component 'consul/intention/list/check' item=this.checkedItem)
''
)
CustomResourceNotice=(if this.isManagedByCRDs
(component 'consul/intention/notice/custom-resource')
''
)
) as |api|}}
{{#if (gt @items.length 0)}}
{{#if (gt this.items.length 0)}}
{{yield api to="idle"}}
{{else}}
{{yield api to="empty"}}
{{/if}}
{{/let}}
</BlockSlot>

View File

@ -0,0 +1,49 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { sort } from '@ember/object/computed';
export default class ConsulIntentionList extends Component {
@service('filter') filter;
@service('sort') sort;
@service('search') search;
@service('repository/intention') repo;
@sort('searched', 'comparator') sorted;
@tracked isManagedByCRDs;
constructor(owner, args) {
super(...arguments);
this.updateCRDManagement(args.items);
}
get items() {
return this.sorted;
}
get filtered() {
const predicate = this.filter.predicate('intention');
return this.args.items.filter(predicate(this.args.filters))
}
get searched() {
if(typeof this.args.search === 'undefined') {
return this.filtered;
}
const predicate = this.search.predicate('intention');
return this.filtered.filter(predicate(this.args.search));
}
get comparator() {
return [this.args.sort];
}
get checkedItem() {
if(this.searched.length === 1) {
return this.searched[0].SourceName === this.args.search ? this.searched[0] : null;
}
return null;
}
@action
updateCRDManagement() {
this.isManagedByCRDs = this.repo.isManagedByCRDs();
}
}

View File

@ -1,10 +1,15 @@
export default (collection, clickable, attribute, deletable) => () => {
return collection('.consul-intention-list [data-test-tabular-row]', {
export default (collection, clickable, attribute, isPresent, deletable) => (scope = '.consul-intention-list') => {
const row = {
source: attribute('data-test-intention-source', '[data-test-intention-source]'),
destination: attribute('data-test-intention-destination', '[data-test-intention-destination]'),
action: attribute('data-test-intention-action', '[data-test-intention-action]'),
intention: clickable('a'),
actions: clickable('label'),
...deletable(),
});
};
return {
scope: scope,
customResourceNotice: isPresent('.consul-intention-notice-custom-resource'),
intentions: collection('[data-test-tabular-row]', row)
}
};

View File

@ -0,0 +1,19 @@
<Notice
class="consul-intention-notice-custom-resource crd"
...attributes
@type={{or @type "info"}}
as |notice|>
<notice.Header>
<h3>
Intention Custom Resource
</h3>
</notice.Header>
<notice.Body>
<p>
Some of your intentions are being managed through an Intention Custom Resource in your Kubernetes cluster. Those managed intentions will be view only in the UI. Any intentions created in the UI will work but will not be synced to the Custom Resource Definition (CRD) datastore.
</p>
<p>
<a href="{{env 'CONSUL_DOCS_URL'}}/k8s/crds" target="_blank" rel="noopener noreferrer">Learn more about CRDs</a>
</p>
</notice.Body>
</Notice>

View File

@ -1,41 +0,0 @@
import intention from 'consul-ui/search/filters/intention';
import token from 'consul-ui/search/filters/token';
import policy from 'consul-ui/search/filters/policy';
import role from 'consul-ui/search/filters/role';
import kv from 'consul-ui/search/filters/kv';
import acl from 'consul-ui/search/filters/acl';
import node from 'consul-ui/search/filters/node';
// service instance
import nodeService from 'consul-ui/search/filters/node/service';
import serviceNode from 'consul-ui/search/filters/service/node';
import service from 'consul-ui/search/filters/service';
import nspace from 'consul-ui/search/filters/nspace';
import filterableFactory from 'consul-ui/utils/search/filterable';
const filterable = filterableFactory();
export function initialize(application) {
// Service-less injection using private properties at a per-project level
const Builder = application.resolveRegistration('service:search');
const searchables = {
intention: intention(filterable),
token: token(filterable),
acl: acl(filterable),
policy: policy(filterable),
role: role(filterable),
kv: kv(filterable),
node: node(filterable),
serviceInstance: serviceNode(filterable),
nodeservice: nodeService(filterable),
service: service(filterable),
nspace: nspace(filterable),
};
Builder.reopen({
searchable: function(name) {
return searchables[name];
},
});
}
export default {
initialize,
};

View File

@ -1,15 +0,0 @@
import { get } from '@ember/object';
export default function(filterable) {
return filterable(function(item, { s = '' }) {
const source = get(item, 'SourceName').toLowerCase();
const destination = get(item, 'DestinationName').toLowerCase();
const sLower = s.toLowerCase();
const allLabel = 'All Services (*)'.toLowerCase();
return (
source.indexOf(sLower) !== -1 ||
destination.indexOf(sLower) !== -1 ||
(source === '*' && allLabel.indexOf(sLower) !== -1) ||
(destination === '*' && allLabel.indexOf(sLower) !== -1)
);
});
}

View File

@ -0,0 +1,12 @@
export default () => (term) => (item) => {
const source = item.SourceName.toLowerCase();
const destination = item.DestinationName.toLowerCase();
const allLabel = 'All Services (*)'.toLowerCase();
const lowerTerm = term.toLowerCase();
return (
source.indexOf(lowerTerm) !== -1 ||
destination.indexOf(lowerTerm) !== -1 ||
(source === '*' && allLabel.indexOf(lowerTerm) !== -1) ||
(destination === '*' && allLabel.indexOf(lowerTerm) !== -1)
);
}

View File

@ -1,46 +1,59 @@
import { set, get } from '@ember/object';
import RepositoryService from 'consul-ui/services/repository';
import { PRIMARY_KEY } from 'consul-ui/models/intention';
const modelName = 'intention';
export default RepositoryService.extend({
getModelName: function() {
export default class IntentionRepository extends RepositoryService {
managedByCRDs = false;
getModelName() {
return modelName;
},
getPrimaryKey: function() {
}
getPrimaryKey() {
return PRIMARY_KEY;
},
create: function(obj) {
}
create(obj) {
delete obj.Namespace;
return this._super({
return super.create({
Action: 'allow',
...obj,
});
},
persist: function(obj) {
return this._super(...arguments).then(res => {
// if Action is set it means we are an l4 type intention
// we don't delete these at a UI level incase the user
// would like to switch backwards and forwards between
// allow/deny/l7 in the forms, but once its been saved
// to the backend we then delete them
if (get(res, 'Action.length')) {
set(res, 'Permissions', []);
}
return res;
});
},
findByService: function(slug, dc, nspace, configuration = {}) {
}
isManagedByCRDs() {
if(!this.managedByCRDs) {
this.managedByCRDs = this.store.peekAll(this.getModelName())
.toArray().some(item => item.IsManagedByCRD);
}
return this.managedByCRDs;
}
async persist(obj) {
const res = await super.persist(...arguments);
// if Action is set it means we are an l4 type intention
// we don't delete these at a UI level incase the user
// would like to switch backwards and forwards between
// allow/deny/l7 in the forms, but once its been saved
// to the backend we then delete them
if (get(res, 'Action.length')) {
set(res, 'Permissions', []);
}
return res;
}
async findByService(slug, dc, nspace, configuration = {}) {
const query = {
dc: dc,
nspace: nspace,
dc,
nspace,
filter: `SourceName == "${slug}" or DestinationName == "${slug}" or SourceName == "*" or DestinationName == "*"`,
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
query.uri = configuration.uri;
}
return this.store.query(this.getModelName(), {
...query,
});
},
});
return this.store.query(this.getModelName(), query);
}
}

View File

@ -1,9 +1,40 @@
import Service from '@ember/service';
export default Service.extend({
searchable: function() {
return {
addEventListener: function() {},
removeEventListener: function() {},
};
},
});
import intention from 'consul-ui/search/predicates/intention';
import token from 'consul-ui/search/filters/token';
import policy from 'consul-ui/search/filters/policy';
import role from 'consul-ui/search/filters/role';
import kv from 'consul-ui/search/filters/kv';
import acl from 'consul-ui/search/filters/acl';
import node from 'consul-ui/search/filters/node';
// service instance
import nodeService from 'consul-ui/search/filters/node/service';
import serviceNode from 'consul-ui/search/filters/service/node';
import service from 'consul-ui/search/filters/service';
import nspace from 'consul-ui/search/filters/nspace';
import filterableFactory from 'consul-ui/utils/search/filterable';
const filterable = filterableFactory();
const searchables = {
token: token(filterable),
acl: acl(filterable),
policy: policy(filterable),
role: role(filterable),
kv: kv(filterable),
node: node(filterable),
serviceInstance: serviceNode(filterable),
nodeservice: nodeService(filterable),
service: service(filterable),
nspace: nspace(filterable),
};
const predicates = {
intention: intention(),
};
export default class SearchService extends Service {
searchable(name) {
return searchables[name];
}
predicate(name) {
return predicates[name];
}
}

View File

@ -1,6 +1,7 @@
%notice {
border-radius: $decor-radius-100;
border: 1px solid;
color: $black;
}
%notice p:last-child a:only-child {
@extend %p3;
@ -22,7 +23,6 @@
%notice-info {
border-color: $blue-100;
background-color: $gray-010;
color: $black;
}
%notice-info header * {
color: $blue-700;
@ -31,7 +31,11 @@
@extend %frame-gray-800;
}
%notice-warning {
@extend %frame-yellow-500;
border-color: $yellow-100;
background-color: $yellow-050;
}
%notice-warning header * {
color: $yellow-800;
}
%notice-error {
@extend %frame-red-500;

View File

@ -22,6 +22,7 @@
<a data-test-create href="{{href-to 'dc.intentions.create'}}" class="type-create">Create</a>
</BlockSlot>
<BlockSlot @name="toolbar">
{{#if (gt items.length 0) }}
<Consul::Intention::SearchBar
@search={{search}}
@ -34,58 +35,55 @@
@onfilter={{hash
access=(action (mut access) value="target.selectedItems")
}}
/>
/>
{{/if}}
</BlockSlot>
<BlockSlot @name="content">
{{#let (filter (filter-predicate 'intention' filters) items) as |filtered|}}
{{#let (sort-by (comparator 'intention' sort) filtered) as |sorted|}}
<ChangeableSet @dispatcher={{searchable 'intention' sorted}} @terms={{search}}>
<BlockSlot @name="content" as |searched|>
<Consul::Intention::List
@items={{searched}}
@ondelete={{refresh-route}}
>
<:idle as |list|>
<list.Table />
</:idle>
<:empty as |list|>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>
{{#if (gt items.length 0)}}
No intentions found
{{else}}
Welcome to Intentions
{{/if}}
</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
{{#if (gt items.length 0)}}
No intentions where found matching that search, or you may not have access to view the intentions you are searching for.
{{else}}
There don't seem to be any intentions, or you may not have access to view intentions yet.
{{/if}}
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/commands/intention" rel="noopener noreferrer" target="_blank">Documentation on intentions</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/getting-started/connect" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
</:empty>
</Consul::Intention::List>
</BlockSlot>
</ChangeableSet>
{{/let}}
{{/let}}
</BlockSlot>
</AppView>
<Consul::Intention::List
@sort={{sort}}
@filters={{filters}}
@search={{search}}
@items={{items}}
@ondelete={{refresh-route}}
>
<:idle as |list|>
<list.CustomResourceNotice />
<list.Table />
</:idle>
<:empty as |list|>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>
{{#if (gt items.length 0)}}
No intentions found
{{else}}
Welcome to Intentions
{{/if}}
</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
{{#if (gt items.length 0)}}
No intentions where found matching that search, or you may not have access to view the intentions you are searching for.
{{else}}
There don't seem to be any intentions, or you may not have access to view intentions yet.
{{/if}}
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/commands/intention" rel="noopener noreferrer" target="_blank">Documentation on intentions</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/getting-started/connect" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
</:empty>
</Consul::Intention::List>
</BlockSlot>
</AppView>
{{/let}}
{{/let}}
{{/let}}

View File

@ -27,24 +27,17 @@
}}
/>
{{/if}}
{{#let (filter (filter-predicate 'intention' filters) items) as |filtered|}}
{{#let (sort-by (comparator 'intention' sort) filtered) as |sorted|}}
<ChangeableSet @dispatcher={{searchable 'intention' sorted}} @terms={{search}}>
<BlockSlot @name="content" as |searched|>
<Consul::Intention::List
@items={{searched}}
@sort={{sort}}
@filters={{filters}}
@search={{search}}
@items={{items}}
@ondelete={{refresh-route}}
@routeName="dc.services.show.intentions.edit"
>
<:idle as |list|>
{{#if (eq searched.length 1)}}
{{#let searched.firstObject as |item|}}
{{#if (eq search item.SourceName)}}
<list.Check @item={{item}} />
{{/if}}
{{/let}}
{{/if}}
<list.CustomResourceNotice />
<list.CheckNotice />
<list.Table />
</:idle>
<:empty as |list|>
@ -58,10 +51,6 @@
</:empty>
</Consul::Intention::List>
</BlockSlot>
</ChangeableSet>
{{/let}}
{{/let}}
</div>
</div>
{{/let}}

View File

@ -16,9 +16,9 @@ Feature: dc / intentions / deleting: Deleting items with confirmations, success
---
dc: datacenter
---
And I click actions on the intentions
And I click delete on the intentions
And I click confirmDelete on the intentions
And I click actions on the intentionList.intentions
And I click delete on the intentionList.intentions
And I click confirmDelete on the intentionList.intentions
Then a DELETE request was made to "/v1/connect/intentions/exact?source=default%2Fname&destination=default%2Fdestination&dc=datacenter"
And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class

View File

@ -9,4 +9,46 @@ Feature: dc / intentions / index
---
Then the url should be /dc-1/intentions
And the title should be "Intentions - Consul"
Then I see 3 intention models
Then I see 3 intention models on the intentionList component
Scenario: Viewing intentions in the listing live updates
Given 1 datacenter model with the value "dc-1"
Given 3 intention models
And a network latency of 100
When I visit the intentions page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/intentions
And pause until I see 3 intention models on the intentionList component
And an external edit results in 5 intention models
And pause until I see 5 intention models on the intentionList component
And an external edit results in 1 intention model
And pause until I see 1 intention models on the intentionList component
And an external edit results in 0 intention models
And pause until I see 0 intention models on the intentionList component
Scenario: Viewing intentions in the listing with CRDs
Given 1 datacenter model with the value "dc-1"
And 1 intention models from yaml
---
Meta:
external-source: kubernetes
---
When I visit the intentions page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/intentions
Then I see customResourceNotice on the intentionList
Scenario: Viewing intentions in the listing without CRDs
Given 1 datacenter model with the value "dc-1"
And 1 intention models from yaml
---
Meta:
external-source: consul
---
When I visit the intentions page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/intentions
Then I don't see customResourceNotice on the intentionList

View File

@ -21,12 +21,12 @@ Feature: dc / intentions / navigation
---
Then the url should be /dc-1/intentions
And the title should be "Intentions - Consul"
Then I see 3 intention models
Then I see 3 intention models on the intentionList component
Given 1 intention model from yaml
---
ID: 755b72bd-f5ab-4c92-90cc-bed0e7d8e9f0
---
When I click intention on the intentions
When I click intention on the intentionList.intentions component
Then a GET request was made to "/v1/internal/ui/services?dc=dc-1&ns=*"
And I click "[data-test-back]"
Then the url should be /dc-1/intentions
@ -37,7 +37,7 @@ Feature: dc / intentions / navigation
---
Then the url should be /dc-1/intentions
And the title should be "Intentions - Consul"
Then I see 3 intention models
Then I see 3 intention models on the intentionList component
When I click create
Then the url should be /dc-1/intentions/create
And I click "[data-test-back]"

View File

@ -1,42 +1,42 @@
@setupApplicationTest
@notNamespaceable
Feature: dc / intentions / sorting
Scenario: Sorting Intentions
Given 1 datacenter model with the value "dc-1"
And 6 intention models from yaml
---
- Action: "allow"
- Action: "allow"
- Action: "deny"
- Action: "deny"
- Action: "allow"
- Action: "deny"
---
When I visit the intentions page for yaml
---
dc: dc-1
---
Then I see 6 intention models
When I click selected on the sort
When I click options.1.button on the sort
Then I see action on the intentions vertically like yaml
---
- "deny"
- "deny"
- "deny"
- "allow"
- "allow"
- "allow"
---
When I click selected on the sort
When I click options.0.button on the sort
Then I see action on the intentions vertically like yaml
---
- "allow"
- "allow"
- "allow"
- "deny"
- "deny"
- "deny"
---
@setupApplicationTest
@notNamespaceable
Feature: dc / intentions / sorting
Scenario: Sorting Intentions
Given 1 datacenter model with the value "dc-1"
And 6 intention models from yaml
---
- Action: "allow"
- Action: "allow"
- Action: "deny"
- Action: "deny"
- Action: "allow"
- Action: "deny"
---
When I visit the intentions page for yaml
---
dc: dc-1
---
Then I see 6 intention models on the intentionList component
When I click selected on the sort
When I click options.1.button on the sort
Then I see action on the intentionList.intentions vertically like yaml
---
- "deny"
- "deny"
- "deny"
- "allow"
- "allow"
- "allow"
---
When I click selected on the sort
When I click options.0.button on the sort
Then I see action on the intentionList.intentions vertically like yaml
---
- "allow"
- "allow"
- "allow"
- "deny"
- "deny"
- "deny"
---

View File

@ -24,7 +24,6 @@ Feature: dc / list-blocking
------------------------------------------------
| Page | Model | Url |
| nodes | node | nodes |
| intentions | intention | intentions |
------------------------------------------------
Scenario: Viewing detail pages with a listing for [Page]
Given 3 [Model] models

View File

@ -37,11 +37,11 @@ Feature: dc / services / show / intentions: Intentions per service
When I click intentions on the tabs
And I see intentionsIsSelected on the tabs
Scenario: I can see intentions
And I see 3 intention models
And I see 3 intention models on the intentionList component
Scenario: I can delete intentions
And I click actions on the intentions
And I click delete on the intentions
And I click confirmDelete on the intentions
And I click actions on the intentionList.intentions component
And I click delete on the intentionList.intentions component
And I click confirmDelete on the intentionList.intentions
Then a DELETE request was made to "/v1/connect/intentions/exact?source=default%2Fname&destination=default%2Fdestination&dc=dc1"
And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class

View File

@ -94,7 +94,7 @@ const morePopoverMenu = morePopoverMenuFactory(clickable);
const popoverSelect = popoverSelectFactory(clickable, collection);
const emptyState = emptyStateFactory(isPresent);
const consulIntentionList = consulIntentionListFactory(collection, clickable, attribute, deletable);
const consulIntentionList = consulIntentionListFactory(collection, clickable, attribute, isPresent, deletable);
const consulNspaceList = consulNspaceListFactory(
collection,
clickable,

View File

@ -1,8 +1,8 @@
export default function(visitable, creatable, clickable, intentions, popoverSelect) {
return creatable({
return {
visit: visitable('/:dc/intentions'),
intentions: intentions(),
intentionList: intentions(),
sort: popoverSelect('[data-test-sort-control]'),
create: clickable('[data-test-create]'),
});
...creatable({})
}
}

View File

@ -21,7 +21,7 @@ export default function(visitable, attribute, collection, text, intentions, filt
instances: collection('.consul-service-instance-list > ul > li:not(:first-child)', {
address: text('[data-test-address]'),
}),
intentions: intentions(),
intentionList: intentions(),
};
page.tabs.upstreamsTab = {
services: collection('.consul-upstream-list > ul > li:not(:first-child)', {

View File

@ -11,6 +11,18 @@ export default function(scenario, assert, find, currentPage, pauseUntil, plurali
return retry();
}, `Expected ${num} ${model}s`);
})
.then('pause until I see $number $model model[s]? on the $component component', function(num, model, component) {
return pauseUntil(function(resolve, reject, retry) {
const obj = find(component);
const len = obj[pluralize(model)].filter(function(item) {
return item.isVisible;
}).length;
if (len === num) {
return resolve();
}
return retry();
}, `Expected ${num} ${model}s`);
})
.then(['I see $num $model model[s]?'], function(num, model) {
const len = currentPage()[pluralize(model)].filter(function(item) {
return item.isVisible;

View File

@ -101,7 +101,7 @@ export default function(scenario, assert, find, currentPage, $) {
component,
yaml
) {
const _component = currentPage()[component];
const _component = find(component);
const iterator = new Array(_component.length).fill(true);
assert.ok(iterator.length > 0);

View File

@ -4,7 +4,11 @@ export default function(scenario, find, click) {
return click(selector);
})
// TODO: Probably nicer to think of better vocab than having the 'without " rule'
.when(['I click (?!")$property(?!")', 'I click $property on the $component'], function(
.when([
'I click (?!")$property(?!")',
'I click $property on the $component',
'I click $property on the $component component'
], function(
property,
component,
next

View File

@ -1,8 +1,8 @@
import getFilter from 'consul-ui/search/filters/intention';
import getPredicate from 'consul-ui/search/predicates/intention';
import { module, test } from 'qunit';
module('Unit | Search | Filter | intention', function() {
const filter = getFilter(cb => cb);
module('Unit | Search | Predicate | intention', function() {
const predicate = getPredicate();
test('items are found by properties', function(assert) {
[
{
@ -14,9 +14,7 @@ module('Unit | Search | Filter | intention', function() {
DestinationName: 'hiT',
},
].forEach(function(item) {
const actual = filter(item, {
s: 'hit',
});
const actual = predicate('hit')(item);
assert.ok(actual);
});
});
@ -27,9 +25,7 @@ module('Unit | Search | Filter | intention', function() {
DestinationName: 'destination',
},
].forEach(function(item) {
const actual = filter(item, {
s: '*',
});
const actual = predicate('*')(item);
assert.notOk(actual);
});
});
@ -44,9 +40,7 @@ module('Unit | Search | Filter | intention', function() {
DestinationName: '*',
},
].forEach(function(item) {
const actual = filter(item, {
s: '*',
});
const actual = predicate('*')(item);
assert.ok(actual);
});
});
@ -62,9 +56,7 @@ module('Unit | Search | Filter | intention', function() {
},
].forEach(function(item) {
['All Services (*)', 'SerVices', '(*)', '*', 'vIces', 'lL Ser'].forEach(function(term) {
const actual = filter(item, {
s: term,
});
const actual = predicate(term)(item);
assert.ok(actual);
});
});