ui: Topology intention saving improvements (#9513)
* ui: Keep track of existing intentions and use those to save changes Previously we risked overwriting existing data in an intention if we tried to save an intention without having loaded it first, for example Description and Metadata would have been overwritten. This change loads in all the intentions for an origin service so we can pick off the one we need to save and change to ensure that we don't overwrite any existing data.
This commit is contained in:
parent
88ab4cd159
commit
3372e6d14c
|
@ -0,0 +1,3 @@
|
||||||
|
```release-note:bug
|
||||||
|
ui: Fixes an issue where intention description or metadata could be overwritten if saved from the topology view.
|
||||||
|
```
|
|
@ -3,7 +3,7 @@
|
||||||
<div
|
<div
|
||||||
{{did-insert (action this.calculate)}}
|
{{did-insert (action this.calculate)}}
|
||||||
{{did-update (action this.calculate) @topology.Upstreams @topology.Downstreams}}
|
{{did-update (action this.calculate) @topology.Upstreams @topology.Downstreams}}
|
||||||
class="topology-container"
|
class="topology-container consul-topology-metrics"
|
||||||
>
|
>
|
||||||
{{#if (gt @topology.Downstreams.length 0)}}
|
{{#if (gt @topology.Downstreams.length 0)}}
|
||||||
<div id="downstream-container">
|
<div id="downstream-container">
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
{{#if (eq @type 'create')}}
|
||||||
|
{{#if (eq @status 'success') }}
|
||||||
|
Your intention has been added.
|
||||||
|
{{else}}
|
||||||
|
There was an error adding your intention.
|
||||||
|
{{/if}}
|
||||||
|
{{else if (eq @type 'update') }}
|
||||||
|
{{#if (eq @status 'success') }}
|
||||||
|
Your intention has been saved.
|
||||||
|
{{else}}
|
||||||
|
There was an error saving your intention.
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
{{#let @error.errors.firstObject as |error|}}
|
||||||
|
{{#if error.detail }}
|
||||||
|
<br />{{concat '(' (if error.status (concat error.status ': ')) error.detail ')'}}
|
||||||
|
{{/if}}
|
||||||
|
{{/let}}
|
||||||
|
|
|
@ -16,16 +16,25 @@
|
||||||
</:header>
|
</:header>
|
||||||
<:body>
|
<:body>
|
||||||
<p>
|
<p>
|
||||||
Add an intention that allows these two services to connect.
|
{{#if @item.Intention.HasExact}}
|
||||||
|
Change the action of this intention to allow.
|
||||||
|
{{else}}
|
||||||
|
Add an intention that allows these two services to connect.
|
||||||
|
{{/if}}
|
||||||
</p>
|
</p>
|
||||||
</:body>
|
</:body>
|
||||||
<:actions as |Actions|>
|
<:actions as |Actions|>
|
||||||
<Actions.Action class="action">
|
<Actions.Action class="action">
|
||||||
<button
|
<button
|
||||||
{{on "click" @oncreate}}
|
{{on "click" @oncreate}}
|
||||||
|
data-test-confirm
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Create
|
{{#if @item.Intention.HasExact}}
|
||||||
|
Allow
|
||||||
|
{{else}}
|
||||||
|
Create
|
||||||
|
{{/if}}
|
||||||
</button>
|
</button>
|
||||||
</Actions.Action>
|
</Actions.Action>
|
||||||
<Actions.Action>
|
<Actions.Action>
|
||||||
|
@ -86,10 +95,11 @@
|
||||||
)
|
)
|
||||||
returns=(set this 'popoverController')
|
returns=(set this 'popoverController')
|
||||||
}}
|
}}
|
||||||
type="button"
|
|
||||||
{{on 'click' (fn (optional this.popoverController.show))}}
|
{{on 'click' (fn (optional this.popoverController.show))}}
|
||||||
|
type="button"
|
||||||
style={{{concat 'top:' @position.y 'px;left:' @position.x 'px;'}}}
|
style={{{concat 'top:' @position.y 'px;left:' @position.x 'px;'}}}
|
||||||
aria-label={{if (eq @type 'deny') 'Add intention' 'View intention'}}
|
aria-label={{if (eq @type 'deny') 'Add intention' 'View intention'}}
|
||||||
|
data-test-action
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,26 +1,62 @@
|
||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
import { get, action } from '@ember/object';
|
import { set, get, action } from '@ember/object';
|
||||||
|
|
||||||
export default class TopologyRoute extends Route {
|
export default class TopologyRoute extends Route {
|
||||||
@service('ui-config') config;
|
@service('ui-config') config;
|
||||||
@service('data-source/service') data;
|
@service('data-source/service') data;
|
||||||
@service('repository/intention') repo;
|
@service('repository/intention') repo;
|
||||||
|
@service('feedback') feedback;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async createIntention(source, destination) {
|
async createIntention(source, destination) {
|
||||||
const model = this.repo.create({
|
// begin with a create action as it makes more sense if the we can't even
|
||||||
Datacenter: source.Datacenter,
|
// get a list of intentions
|
||||||
SourceName: source.Name,
|
let notification = this.feedback.notification('create');
|
||||||
SourceNS: source.Namespace || 'default',
|
try {
|
||||||
DestinationName: destination.Name,
|
// intentions will be a proxy object
|
||||||
DestinationNS: destination.Namespace || 'default',
|
let intentions = await this.intentions;
|
||||||
Action: 'allow',
|
let intention = intentions.find(item => {
|
||||||
});
|
return (
|
||||||
await this.repo.persist(model);
|
item.Datacenter === source.Datacenter &&
|
||||||
|
item.SourceName === source.Name &&
|
||||||
|
item.SourceNS === source.Namespace &&
|
||||||
|
item.DestinationName === destination.Name &&
|
||||||
|
item.DestinationNS === destination.Namespace
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (typeof intention === 'undefined') {
|
||||||
|
intention = this.repo.create({
|
||||||
|
Datacenter: source.Datacenter,
|
||||||
|
SourceName: source.Name,
|
||||||
|
SourceNS: source.Namespace || 'default',
|
||||||
|
DestinationName: destination.Name,
|
||||||
|
DestinationNS: destination.Namespace || 'default',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// we found an intention in the find higher up, so we are updating
|
||||||
|
notification = this.feedback.notification('update');
|
||||||
|
}
|
||||||
|
set(intention, 'Action', 'allow');
|
||||||
|
await this.repo.persist(intention);
|
||||||
|
notification.success(intention);
|
||||||
|
} catch (e) {
|
||||||
|
notification.error(e);
|
||||||
|
}
|
||||||
this.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterModel(model, transition) {
|
||||||
|
this.intentions = this.data.source(
|
||||||
|
uri => uri`/${model.nspace}/${model.dc.Name}/intentions/for-service/${model.slug}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(transition) {
|
||||||
|
const intentions = await this.intentions;
|
||||||
|
intentions.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
async model() {
|
async model() {
|
||||||
const parent = this.routeName
|
const parent = this.routeName
|
||||||
.split('.')
|
.split('.')
|
||||||
|
|
|
@ -13,54 +13,64 @@ const notificationDefaults = function() {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export default class FeedbackService extends Service {
|
export default class FeedbackService extends Service {
|
||||||
@service('flashMessages')
|
@service('flashMessages') notify;
|
||||||
notify;
|
@service('logger') logger;
|
||||||
|
|
||||||
@service('logger')
|
notification(action) {
|
||||||
logger;
|
return {
|
||||||
|
success: item => this.success(item, action),
|
||||||
|
error: e => this.error(e, action),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
execute(handle, action, status = defaultStatus, controller) {
|
success(item, action, status = defaultStatus) {
|
||||||
const getAction = callableType(action);
|
const getAction = callableType(action);
|
||||||
const getStatus = callableType(status);
|
const getStatus = callableType(status);
|
||||||
const notify = this.notify;
|
// returning exactly `false` for a feedback action means even though
|
||||||
return (
|
// its successful, please skip this notification and don't display it
|
||||||
handle()
|
if (item !== false) {
|
||||||
//TODO: pass this through to getAction..
|
this.notify.clearMessages();
|
||||||
.then(item => {
|
// TODO right now the majority of `item` is a Transition
|
||||||
// returning exactly `false` for a feedback action means even though
|
// but you can resolve an object
|
||||||
// its successful, please skip this notification and don't display it
|
this.notify.add({
|
||||||
if (item !== false) {
|
...notificationDefaults(),
|
||||||
notify.clearMessages();
|
type: getStatus(TYPE_SUCCESS),
|
||||||
// TODO right now the majority of `item` is a Transition
|
// here..
|
||||||
// but you can resolve an object
|
action: getAction(),
|
||||||
notify.add({
|
item: item,
|
||||||
...notificationDefaults(),
|
});
|
||||||
type: getStatus(TYPE_SUCCESS),
|
}
|
||||||
// here..
|
}
|
||||||
action: getAction(),
|
|
||||||
item: item,
|
error(e, action, status = defaultStatus) {
|
||||||
});
|
const getAction = callableType(action);
|
||||||
}
|
const getStatus = callableType(status);
|
||||||
})
|
this.notify.clearMessages();
|
||||||
.catch(e => {
|
this.logger.execute(e);
|
||||||
notify.clearMessages();
|
if (e.name === 'TransitionAborted') {
|
||||||
this.logger.execute(e);
|
this.notify.add({
|
||||||
if (e.name === 'TransitionAborted') {
|
...notificationDefaults(),
|
||||||
notify.add({
|
type: getStatus(TYPE_SUCCESS),
|
||||||
...notificationDefaults(),
|
// and here
|
||||||
type: getStatus(TYPE_SUCCESS),
|
action: getAction(),
|
||||||
// and here
|
});
|
||||||
action: getAction(),
|
} else {
|
||||||
});
|
this.notify.add({
|
||||||
} else {
|
...notificationDefaults(),
|
||||||
notify.add({
|
type: getStatus(TYPE_ERROR, e),
|
||||||
...notificationDefaults(),
|
action: getAction(),
|
||||||
type: getStatus(TYPE_ERROR, e),
|
error: e,
|
||||||
action: getAction(),
|
});
|
||||||
error: e,
|
}
|
||||||
});
|
}
|
||||||
}
|
|
||||||
})
|
async execute(handle, action, status) {
|
||||||
);
|
let result;
|
||||||
|
try {
|
||||||
|
result = await handle();
|
||||||
|
this.success(result, action, status);
|
||||||
|
} catch (e) {
|
||||||
|
this.error(e, action, status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,13 @@
|
||||||
|
|
||||||
<BlockSlot @name="loaded">
|
<BlockSlot @name="loaded">
|
||||||
<AppView>
|
<AppView>
|
||||||
|
<BlockSlot @name="notification" as |status type item error|>
|
||||||
|
<TopologyMetrics::Notifications
|
||||||
|
@type={{type}}
|
||||||
|
@status={{status}}
|
||||||
|
@error={{error}}
|
||||||
|
/>
|
||||||
|
</BlockSlot>
|
||||||
<BlockSlot @name="breadcrumbs">
|
<BlockSlot @name="breadcrumbs">
|
||||||
<ol>
|
<ol>
|
||||||
<li><a data-test-back href={{href-to 'dc.services'}}>All Services</a></li>
|
<li><a data-test-back href={{href-to 'dc.services'}}>All Services</a></li>
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
@setupApplicationTest
|
||||||
|
Feature: dc / services / show / topology: Intention Create
|
||||||
|
Background:
|
||||||
|
Given 1 datacenter model with the value "datacenter"
|
||||||
|
And 1 intention model from yaml
|
||||||
|
---
|
||||||
|
SourceNS: default
|
||||||
|
SourceName: web
|
||||||
|
DestinationNS: default
|
||||||
|
DestinationName: db
|
||||||
|
ID: intention-id
|
||||||
|
---
|
||||||
|
And 1 node model
|
||||||
|
And 1 service model from yaml
|
||||||
|
---
|
||||||
|
- Service:
|
||||||
|
Name: web
|
||||||
|
Kind: ~
|
||||||
|
---
|
||||||
|
And 1 topology model from yaml
|
||||||
|
---
|
||||||
|
Downstreams: []
|
||||||
|
Upstreams:
|
||||||
|
- Name: db
|
||||||
|
Namespace: default
|
||||||
|
Datacenter: datacenter
|
||||||
|
Intention: {}
|
||||||
|
---
|
||||||
|
When I visit the service page for yaml
|
||||||
|
---
|
||||||
|
dc: datacenter
|
||||||
|
service: web
|
||||||
|
---
|
||||||
|
Scenario: Allow a connection between service and upstream by saving an intention
|
||||||
|
When I click ".consul-topology-metrics [data-test-action]"
|
||||||
|
And I click ".consul-topology-metrics [data-test-confirm]"
|
||||||
|
And "[data-notification]" has the "success" class
|
||||||
|
Scenario: There was an error saving the intention
|
||||||
|
Given the url "/v1/connect/intentions/exact?source=default%2Fweb&destination=default%2Fdb&dc=datacenter" responds with a 500 status
|
||||||
|
When I click ".consul-topology-metrics [data-test-action]"
|
||||||
|
And I click ".consul-topology-metrics [data-test-confirm]"
|
||||||
|
And "[data-notification]" has the "error" class
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -38,6 +38,9 @@ export default function(type) {
|
||||||
case 'nspace':
|
case 'nspace':
|
||||||
requests = ['/v1/namespaces', '/v1/namespace/'];
|
requests = ['/v1/namespaces', '/v1/namespace/'];
|
||||||
break;
|
break;
|
||||||
|
case 'topology':
|
||||||
|
requests = ['/v1/internal/ui/service-topology'];
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
// TODO: An instance of URL should come in here (instead of 2 args)
|
// TODO: An instance of URL should come in here (instead of 2 args)
|
||||||
return function(url, method) {
|
return function(url, method) {
|
||||||
|
|
Loading…
Reference in New Issue