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:
John Cowen 2021-01-19 15:40:39 +00:00 committed by GitHub
parent 88ab4cd159
commit 3372e6d14c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 200 additions and 59 deletions

3
.changelog/9513.txt Normal file
View File

@ -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.
```

View File

@ -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">

View File

@ -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}}

View File

@ -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>

View File

@ -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('.')

View File

@ -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);
}
} }
} }

View File

@ -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>

View File

@ -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

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

@ -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) {