ui: Per Service Intentions Tab (#7615)

* Add model layer support for filtering intentions by service

* Add Route, Controller and template for services.show.intentions tab

We are still loading the intentions themselves in the parent Route for
the moment

* Load the intentions in in the parent route for the moment

* Temporarily add support for returning to history -1

Once we have an intention form underneath the service/intention tab this
will no longer be needed

* Add the new tab and enable blocking queries for it

* Add some further acceptance testing around intention listings
This commit is contained in:
John Cowen 2020-04-16 15:15:45 +01:00 committed by John Cowen
parent b43ecb8fa7
commit 455dfe0a1b
19 changed files with 239 additions and 29 deletions

View File

@ -6,11 +6,14 @@ import { SLUG_KEY } from 'consul-ui/models/intention';
// TODO: Update to use this.formatDatacenter() // TODO: Update to use this.formatDatacenter()
export default Adapter.extend({ export default Adapter.extend({
requestForQuery: function(request, { dc, index, id }) { requestForQuery: function(request, { dc, filter, index }) {
return request` return request`
GET /v1/connect/intentions?${{ dc }} GET /v1/connect/intentions?${{ dc }}
${{ index }} ${{
index,
filter,
}}
`; `;
}, },
requestForQueryRecord: function(request, { dc, index, id }) { requestForQueryRecord: function(request, { dc, index, id }) {

View File

@ -0,0 +1,28 @@
import Controller from '@ember/controller';
import { get, computed } from '@ember/object';
import WithSearching from 'consul-ui/mixins/with-searching';
export default Controller.extend(WithSearching, {
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
init: function() {
this.searchParams = {
intention: 's',
};
this._super(...arguments);
},
searchable: computed('intentions', function() {
return get(this, 'searchables.intention')
.add(this.intentions)
.search(get(this, this.searchParams.intention));
}),
actions: {
route: function() {
this.send(...arguments);
},
},
});

View File

@ -61,6 +61,7 @@ export function initialize(container) {
services: { services: {
repo: 'repository/service/event-source', repo: 'repository/service/event-source',
chainRepo: 'repository/discovery-chain/event-source', chainRepo: 'repository/discovery-chain/event-source',
intentionRepo: 'repository/intention/event-source',
}, },
}, },
{ {

View File

@ -1,5 +1,6 @@
import Mixin from '@ember/object/mixin'; import Mixin from '@ember/object/mixin';
import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions'; import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions';
import { get } from '@ember/object';
import { INTERNAL_SERVER_ERROR as HTTP_INTERNAL_SERVER_ERROR } from 'consul-ui/utils/http/status'; import { INTERNAL_SERVER_ERROR as HTTP_INTERNAL_SERVER_ERROR } from 'consul-ui/utils/http/status';
export default Mixin.create(WithBlockingActions, { export default Mixin.create(WithBlockingActions, {
@ -14,4 +15,25 @@ export default Mixin.create(WithBlockingActions, {
} }
return type; return type;
}, },
afterUpdate: function(item) {
if (get(this, 'history.length') > 0) {
return this.transitionTo(this.history[0].key, this.history[0].value);
}
return this._super(...arguments);
},
afterCreate: function(item) {
if (get(this, 'history.length') > 0) {
return this.transitionTo(this.history[0].key, this.history[0].value);
}
return this._super(...arguments);
},
afterDelete: function(item) {
if (get(this, 'history.length') > 0) {
return this.transitionTo(this.history[0].key, this.history[0].value);
}
if (this.routeName === 'dc.services.show') {
return this.transitionTo(this.routeName, this._router.currentRoute.params.name);
}
return this._super(...arguments);
},
}); });

View File

@ -10,7 +10,19 @@ export default Route.extend(WithIntentionActions, {
repo: service('repository/intention'), repo: service('repository/intention'),
servicesRepo: service('repository/service'), servicesRepo: service('repository/service'),
nspacesRepo: service('repository/nspace/disabled'), nspacesRepo: service('repository/nspace/disabled'),
model: function(params) { buildRouteInfoMetadata: function() {
return { history: this.history };
},
model: function(params, transition) {
const from = get(transition, 'from');
this.history = [];
if (from && get(from, 'name') === 'dc.services.show.intentions') {
this.history.push({
key: get(from, 'name'),
value: get(from, 'parent.params.name'),
});
}
const dc = this.modelFor('dc').dc.Name; const dc = this.modelFor('dc').dc.Name;
// We load all of your services that you are able to see here // We load all of your services that you are able to see here
// as even if it doesn't exist in the namespace you are targetting // as even if it doesn't exist in the namespace you are targetting
@ -21,6 +33,7 @@ export default Route.extend(WithIntentionActions, {
item: this.repo.findBySlug(params.id, dc, nspace), item: this.repo.findBySlug(params.id, dc, nspace),
services: this.servicesRepo.findAllByDatacenter(dc, nspace), services: this.servicesRepo.findAllByDatacenter(dc, nspace),
nspaces: this.nspacesRepo.findAll(), nspaces: this.nspacesRepo.findAll(),
history: this.history,
}).then(function(model) { }).then(function(model) {
return { return {
...model, ...model,

View File

@ -5,15 +5,18 @@ import { get } from '@ember/object';
export default Route.extend({ export default Route.extend({
repo: service('repository/service'), repo: service('repository/service'),
intentionRepo: service('repository/intention'),
chainRepo: service('repository/discovery-chain'), chainRepo: service('repository/discovery-chain'),
settings: service('settings'), settings: service('settings'),
model: function(params) { model: function(params, transition = {}) {
const dc = this.modelFor('dc').dc.Name; const dc = this.modelFor('dc').dc.Name;
const nspace = this.modelFor('nspace').nspace.substr(1); const nspace = this.modelFor('nspace').nspace.substr(1);
return hash({ return hash({
item: this.repo.findBySlug(params.name, dc, nspace), item: this.repo.findBySlug(params.name, dc, nspace),
intentions: this.intentionRepo.findByService(params.name, dc, nspace),
urls: this.settings.findBySlug('urls'), urls: this.settings.findBySlug('urls'),
dc: dc, dc: dc,
nspace: nspace,
}).then(model => { }).then(model => {
return hash({ return hash({
chain: ['connect-proxy', 'mesh-gateway'].includes(get(model, 'item.Service.Kind')) chain: ['connect-proxy', 'mesh-gateway'].includes(get(model, 'item.Service.Kind'))

View File

@ -1,6 +1,9 @@
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import WithIntentionActions from 'consul-ui/mixins/intention/with-actions';
export default Route.extend({ export default Route.extend(WithIntentionActions, {
repo: service('repository/intention'),
model: function() { model: function() {
const parent = this.routeName const parent = this.routeName
.split('.') .split('.')
@ -11,4 +14,8 @@ export default Route.extend({
setupController: function(controller, model) { setupController: function(controller, model) {
controller.setProperties(model); controller.setProperties(model);
}, },
// Overwrite default afterDelete action to just refresh
afterDelete: function() {
return this.refresh();
},
}); });

View File

@ -8,4 +8,17 @@ export default RepositoryService.extend({
getPrimaryKey: function() { getPrimaryKey: function() {
return PRIMARY_KEY; return PRIMARY_KEY;
}, },
findByService: function(slug, dc, nspace, configuration = {}) {
const query = {
dc: dc,
nspace: nspace,
filter: `SourceName == ${slug} or DestinationName == ${slug}`,
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
}
return this.store.query(this.getModelName(), {
...query,
});
},
}); });

View File

@ -10,7 +10,14 @@
</BlockSlot> </BlockSlot>
<BlockSlot @name="breadcrumbs"> <BlockSlot @name="breadcrumbs">
<ol> <ol>
<li><a data-test-back href={{href-to 'dc.intentions'}}>All Intentions</a></li> {{#if (gt history.length 0)}}
<li><a href={{href-to 'dc.services'}}>All Services</a></li>
{{#let history.firstObject as |back|}}
<li><a data-test-back href={{href-to back.key back.value}}>{{concat 'Service (' back.value ')'}}</a></li>
{{/let}}
{{else}}
<li><a data-test-back href={{href-to 'dc.intentions'}}>All Intentions</a></li>
{{/if}}
</ol> </ol>
</BlockSlot> </BlockSlot>
<BlockSlot @name="header"> <BlockSlot @name="header">

View File

@ -2,6 +2,7 @@
<AppView @class="service show"> <AppView @class="service show">
<BlockSlot @name="notification" as |status type|> <BlockSlot @name="notification" as |status type|>
{{partial 'dc/services/notifications'}} {{partial 'dc/services/notifications'}}
{{partial 'dc/intentions/notifications'}}
</BlockSlot> </BlockSlot>
<BlockSlot @name="breadcrumbs"> <BlockSlot @name="breadcrumbs">
<ol> <ol>
@ -27,6 +28,7 @@
compact compact
(array (array
(hash label="Instances" href=(href-to "dc.services.show.instances") selected=(is-href "dc.services.show.instances")) (hash label="Instances" href=(href-to "dc.services.show.instances") selected=(is-href "dc.services.show.instances"))
(hash label="Intentions" href=(href-to "dc.services.show.intentions") selected=(is-href "dc.services.show.intentions"))
(if (not-eq chain) (hash label="Routing" href=(href-to "dc.services.show.routing") selected=(is-href "dc.services.show.routing")) '') (if (not-eq chain) (hash label="Routing" href=(href-to "dc.services.show.routing") selected=(is-href "dc.services.show.routing")) '')
(hash label="Tags" href=(href-to "dc.services.show.tags") selected=(is-href "dc.services.show.tags")) (hash label="Tags" href=(href-to "dc.services.show.tags") selected=(is-href "dc.services.show.tags"))
) )

View File

@ -0,0 +1,23 @@
<div id="intentions" class="tab-section">
<div role="tabpanel">
{{#if (gt intentions.length 0) }}
<input type="checkbox" id="toolbar-toggle" />
<form class="filter-bar">
<FreetextFilter @searchable={{searchable}} @value={{s}} @placeholder="Search" />
</form>
{{/if}}
<ChangeableSet @dispatcher={{searchable}}>
<BlockSlot @name="set" as |filtered|>
<ConsulIntentionList
@items={{filtered}}
@ondelete={{action "route" "delete"}}
/>
</BlockSlot>
<BlockSlot @name="empty">
<p>
There are no intentions for this service.
</p>
</BlockSlot>
</ChangeableSet>
</div>
</div>

View File

@ -0,0 +1,12 @@
@setupApplicationTest
Feature: dc / intentions / index
Scenario: Viewing intentions in the listing
Given 1 datacenter model with the value "dc-1"
And 3 intention models
When I visit the intentions page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/intentions
And the title should be "Intentions - Consul"
Then I see 3 intention models

View File

@ -0,0 +1,31 @@
@setupApplicationTest
Feature: dc / services / intentions: Intentions per service
Background:
Given 1 datacenter model with the value "dc1"
And 1 node models
And 1 service model from yaml
---
- Service:
Kind: consul
Name: service-0
ID: service-0-with-id
---
And 3 intention models
When I visit the service page for yaml
---
dc: dc1
service: service-0
---
And the title should be "service-0 - Consul"
And I see intentions on the tabs
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
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
Then a DELETE request was made to "/v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=dc1"
And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class

View File

@ -0,0 +1,10 @@
import steps from '../../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -0,0 +1,10 @@
import steps from '../../../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -14,6 +14,8 @@ import createSubmitable from 'consul-ui/tests/lib/page-object/createSubmitable';
import createCreatable from 'consul-ui/tests/lib/page-object/createCreatable'; import createCreatable from 'consul-ui/tests/lib/page-object/createCreatable';
import createCancelable from 'consul-ui/tests/lib/page-object/createCancelable'; import createCancelable from 'consul-ui/tests/lib/page-object/createCancelable';
// TODO: All component-like page objects should be moved into the component folder
// along with all of its other dependencies once we can mae ember-cli ignore them
import page from 'consul-ui/tests/pages/components/page'; import page from 'consul-ui/tests/pages/components/page';
import radiogroup from 'consul-ui/tests/lib/page-object/radiogroup'; import radiogroup from 'consul-ui/tests/lib/page-object/radiogroup';
import tabgroup from 'consul-ui/tests/lib/page-object/tabgroup'; import tabgroup from 'consul-ui/tests/lib/page-object/tabgroup';
@ -26,6 +28,8 @@ import policyFormFactory from 'consul-ui/tests/pages/components/policy-form';
import policySelectorFactory from 'consul-ui/tests/pages/components/policy-selector'; import policySelectorFactory from 'consul-ui/tests/pages/components/policy-selector';
import roleFormFactory from 'consul-ui/tests/pages/components/role-form'; import roleFormFactory from 'consul-ui/tests/pages/components/role-form';
import roleSelectorFactory from 'consul-ui/tests/pages/components/role-selector'; import roleSelectorFactory from 'consul-ui/tests/pages/components/role-selector';
import consulIntentionListFactory from 'consul-ui/tests/pages/components/consul-intention-list';
// TODO: should this specifically be modal or form? // TODO: should this specifically be modal or form?
// should all forms be forms? // should all forms be forms?
@ -65,13 +69,26 @@ const policySelector = policySelectorFactory(clickable, deletable, collection, a
const roleForm = roleFormFactory(submitable, cancelable, policySelector); const roleForm = roleFormFactory(submitable, cancelable, policySelector);
const roleSelector = roleSelectorFactory(clickable, deletable, collection, alias, roleForm); const roleSelector = roleSelectorFactory(clickable, deletable, collection, alias, roleForm);
const consulIntentionList = consulIntentionListFactory(collection, clickable, attribute, deletable);
export default { export default {
index: create(index(visitable, collection)), index: create(index(visitable, collection)),
dcs: create(dcs(visitable, clickable, attribute, collection)), dcs: create(dcs(visitable, clickable, attribute, collection)),
services: create( services: create(
services(visitable, clickable, text, attribute, collection, page, catalogFilter, radiogroup) services(visitable, clickable, text, attribute, collection, page, catalogFilter, radiogroup)
), ),
service: create(service(visitable, attribute, collection, text, catalogFilter, tabgroup)), service: create(
service(
visitable,
clickable,
attribute,
collection,
text,
consulIntentionList,
catalogFilter,
tabgroup
)
),
instance: create(instance(visitable, attribute, collection, text, tabgroup)), instance: create(instance(visitable, attribute, collection, text, tabgroup)),
nodes: create(nodes(visitable, clickable, attribute, collection, catalogFilter)), nodes: create(nodes(visitable, clickable, attribute, collection, catalogFilter)),
node: create(node(visitable, deletable, clickable, attribute, collection, tabgroup)), node: create(node(visitable, deletable, clickable, attribute, collection, tabgroup)),
@ -113,9 +130,7 @@ export default {
token: create( token: create(
token(visitable, submitable, deletable, cancelable, clickable, policySelector, roleSelector) token(visitable, submitable, deletable, cancelable, clickable, policySelector, roleSelector)
), ),
intentions: create( intentions: create(intentions(visitable, creatable, consulIntentionList, intentionFilter)),
intentions(visitable, deletable, creatable, clickable, attribute, collection, intentionFilter)
),
intention: create(intention(visitable, submitable, deletable, cancelable)), intention: create(intention(visitable, submitable, deletable, cancelable)),
nspaces: create( nspaces: create(
nspaces(visitable, deletable, creatable, clickable, attribute, collection, text, freetextFilter) nspaces(visitable, deletable, creatable, clickable, attribute, collection, text, freetextFilter)

View File

@ -0,0 +1,10 @@
export default (collection, clickable, attribute, deletable) => () => {
return collection('.consul-intention-list [data-test-tabular-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(),
});
};

View File

@ -1,19 +1,7 @@
export default function(visitable, deletable, creatable, clickable, attribute, collection, filter) { export default function(visitable, creatable, intentions, filter) {
return creatable({ return creatable({
visit: visitable('/:dc/intentions'), visit: visitable('/:dc/intentions'),
intentions: collection( intentions: intentions(),
'[data-test-tabular-row]',
deletable({
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'),
})
),
filter: filter, filter: filter,
}); });
} }

View File

@ -1,14 +1,26 @@
export default function(visitable, attribute, collection, text, filter, tabs) { export default function(
visitable,
clickable,
attribute,
collection,
text,
intentions,
filter,
tabs
) {
return { return {
visit: visitable('/:dc/services/:service'), visit: visitable('/:dc/services/:service'),
externalSource: attribute('data-test-external-source', 'h1 span'), externalSource: attribute('data-test-external-source', 'h1 span'),
instances: collection('#instances [data-test-tabular-row]', {
address: text('[data-test-address]'),
}),
dashboardAnchor: { dashboardAnchor: {
href: attribute('href', '[data-test-dashboard-anchor]'), href: attribute('href', '[data-test-dashboard-anchor]'),
}, },
tabs: tabs('tab', ['instances', 'routing', 'tags']), tabs: tabs('tab', ['instances', 'intentions', 'routing', 'tags']),
filter: filter, filter: filter,
// TODO: These need to somehow move to subpages
instances: collection('#instances [data-test-tabular-row]', {
address: text('[data-test-address]'),
}),
intentions: intentions(),
}; };
} }