ui: New Intention Form/List components (#8172)

This commit is contained in:
John Cowen 2020-07-09 10:08:47 +01:00 committed by GitHub
parent 5e5dbedd47
commit 0d35548519
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
83 changed files with 2951 additions and 1585 deletions

View File

@ -22,6 +22,7 @@ export default Adapter.extend({
}
return request`
GET /v1/connect/intentions/${id}?${{ dc }}
Cache-Control: no-store
${{ index }}
`;

View File

@ -0,0 +1,10 @@
<AppView @class="error show">
<BlockSlot @name="header">
<h1>
Error {{error.status}}
</h1>
</BlockSlot>
<BlockSlot @name="content">
<ErrorState @error={{error}} />
</BlockSlot>
</AppView>

View File

@ -0,0 +1,5 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
});

View File

@ -1,8 +1,10 @@
{{yield}}
{{#if (not loading)}}
<header>
{{#each flashMessages.queue as |flash|}}
<FlashMessage @flash={{flash}} as |component flash|>
{{#if flash.dom}}
{{{flash.dom}}}
{{else}}
{{#let (lowercase component.flashType) (lowercase flash.action) as |status type|}}
{{! flashes automatically ucfirst the type }}
@ -42,6 +44,7 @@
{{/yield-slot}}
</p>
{{/let}}
{{/if}}
</FlashMessage>
{{/each}}
<div>
@ -57,7 +60,10 @@
</YieldSlot>
<div class="actions">
{{#if authorized}}
<YieldSlot @name="actions">{{yield}}</YieldSlot>
<YieldSlot @name="actions">
<PortalTarget @name="app-view-actions" />
{{yield}}
</YieldSlot>
{{/if}}
</div>
</div>
@ -73,11 +79,7 @@
</YieldSlot>
{{/if}}
</header>
{{/if}}
<div>
{{#if loading}}
<ConsulLoader />
{{else}}
{{#if (not enabled) }}
<YieldSlot @name="disabled">{{yield}}</YieldSlot>
{{else if (not authorized)}}
@ -85,5 +87,4 @@
{{else}}
<YieldSlot @name="content">{{yield}}</YieldSlot>
{{/if}}
{{/if}}
</div>

View File

@ -3,7 +3,6 @@ import SlotsMixin from 'block-slots';
import { inject as service } from '@ember/service';
import templatize from 'consul-ui/utils/templatize';
export default Component.extend(SlotsMixin, {
loading: false,
authorized: true,
enabled: true,
classNames: ['app-view'],
@ -13,12 +12,12 @@ export default Component.extend(SlotsMixin, {
this._super(...arguments);
// right now only manually added classes are hoisted to <html>
const $root = this.dom.root();
let cls = this['class'] || '';
if (this.loading) {
cls += ' loading';
$root.classList.add('loading');
} else {
$root.classList.remove(...templatize(['loading']));
$root.classList.remove('loading');
}
let cls = this['class'] || '';
if (cls) {
// its possible for 'layout' templates to change after insert
// check for these specific layouts and clear them out

View File

@ -1,4 +1,5 @@
{{yield}}
<YieldSlot @name="content" @params={{block-params items}}>{{yield}}</YieldSlot>
{{#if (gt items.length 0)}}
<YieldSlot @name="set" @params={{block-params items}}>{{yield}}</YieldSlot>
{{else}}

View File

@ -1,123 +1,168 @@
<form onsubmit={{action 'submit' _item}}>
<fieldset>
<div role="group">
<fieldset>
<h2>Source</h2>
<label data-test-source-element class="type-select{{if _item.error.SourceName ' has-error'}}">
<span>Source Service</span>
<PowerSelectWithCreate
@options={{_services}}
@searchField="Name"
@selected={{SourceName}}
@searchPlaceholder="Type service name"
@buildSuggestion={{action "createNewLabel" "Use a Consul Service called '{{term}}'"}}
@showCreateWhen={{action "isUnique"}}
@onCreate={{action "change" "SourceName"}}
@onChange={{action "change" "SourceName"}} as |service|>
{{#if (eq service.Name '*') }}
* (All Services)
{{else}}
{{service.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>Search for an existing service, or enter any Service name.</em>
</label>
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
<label data-test-source-nspace class="type-select{{if _item.error.SourceNS ' has-error'}}">
<span>Source Namespace</span>
<PowerSelectWithCreate
@options={{_nspaces}}
@searchField="Name"
@selected={{SourceNS}}
@searchPlaceholder="Type namespace name"
@buildSuggestion={{action "createNewLabel" "Use a Consul Namespace called '{{term}}'"}}
@showCreateWhen={{action "isUnique"}}
@onCreate={{action "change" "SourceNS"}}
@onChange={{action "change" "SourceNS"}} as |nspace|>
{{#if (eq nspace.Name '*') }}
* (All Namespaces)
{{else}}
{{nspace.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>Search for an existing namespace, or enter any Namespace name.</em>
</label>
{{/if}}
</fieldset>
<fieldset>
<h2>Destination</h2>
<label data-test-destination-element class="type-select{{if _item.error.DestinationName ' has-error'}}">
<span>Destination Service</span>
<PowerSelectWithCreate
@options={{_services}}
@searchField="Name"
@selected={{DestinationName}}
@searchPlaceholder="Type service name"
@buildSuggestion={{action "createNewLabel" "Use a Consul Service called '{{term}}'"}}
@showCreateWhen={{action "isUnique"}}
@onCreate={{action "change" "DestinationName"}}
@onChange={{action "change" "DestinationName"}} as |service|>
{{#if (eq service.Name '*') }}
* (All Services)
{{else}}
{{service.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>Search for an existing service, or enter any Service name.</em>
</label>
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
<label data-test-destination-nspace class="type-select{{if _item.error.DestinationNS ' has-error'}}">
<span>Destination Namespace</span>
<PowerSelectWithCreate
@options={{_nspaces}}
@searchField="Name"
@selected={{DestinationNS}}
@searchPlaceholder="Type namespace name"
@buildSuggestion={{action "createNewLabel" "Use a future Consul Namespace called '{{term}}'"}}
@showCreateWhen={{action "isUnique"}}
@onCreate={{action "change" "DestinationNS"}}
@onChange={{action "change" "DestinationNS"}} as |nspace|>
{{#if (eq nspace.Name '*') }}
* (All Namespaces)
{{else}}
{{nspace.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>For the destination, you may choose any namespace for which you have access.</em>
</label>
{{/if}}
</fieldset>
</div>
<div role="radiogroup" class={{if _item.error.Action ' has-error'}}>
{{#each (array 'allow' 'deny') as |intent|}}
<label>
<span>{{ capitalize intent }}</span>
<input type="radio" name="Action" value="{{intent}}" checked={{if (eq _item.Action intent) 'checked'}} onchange={{ action 'change' }}/>
</label>
{{/each}}
</div>
<label class="type-text{{if _item.error.Description ' has-error'}}">
<span>Description (Optional)</span>
<input type="text" name="Description" value="{{_item.Description}}" placeholder="Description (Optional)" onchange={{action 'change'}} />
</label>
</fieldset>
<div>
{{#if _item.isNew }}
<button type="submit" disabled={{if (or _item.isPristine _item.isInvalid) 'disabled'}}>Save</button>
{{ else }}
<button type="submit" disabled={{if _item.isInvalid 'disabled'}}>Save</button>
{{/if}}
<button type="reset" onclick={{action oncancel _item}}>Cancel</button>
{{# if (and _item.ID (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 ondelete _item}}>Delete</button>
</BlockSlot>
<BlockSlot @name="dialog" as |execute cancel message|>
<DeleteConfirmation @message={{message}} @execute={{execute}} @cancel={{cancel}} />
</BlockSlot>
</ConfirmationDialog>
{{/if}}
</div>
</form>
<DataForm
@dc={{dc}}
@nspace={{nspace}}
@type="intention"
@autofill={{autofill}}
@item={{item}}
@src={{src}}
@onchange={{action "change"}}
@onsubmit={{action onsubmit}}
as |api|
>
<BlockSlot @name="error" as |Notification|>
<Notification>
<p data-notification role="alert" class="error notification-update">
{{#if (starts-with 'duplicate intention found:' api.error.detail)}}
<strong>Intention exists</strong>
An intention already exists for this Source-Destination pair. Please enter a different combination of Services, or search the intentions to edit an existing intention.
{{else}}
<strong>Error!</strong>
There was an error saving your intention.
{{#if (and api.error.status api.error.detail)}}
<br />{{api.error.status}}: {{api.error.detail}}
{{/if}}
{{/if}}
</p>
</Notification>
</BlockSlot>
<BlockSlot @name="form">
<DataSource
@src={{concat '/' nspace '/' dc '/services'}}
@onchange={{action "createServices" api.data}}
/>
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
<DataSource
@src="/*/*/namespaces"
@onchange={{action "createNspaces" api.data}}
/>
{{/if}}
<form onsubmit={{action api.submit}}>
<fieldset disabled={{api.disabled}}>
<div role="group">
<fieldset>
<h2>Source</h2>
<label data-test-source-element class="type-select{{if api.data.error.SourceName ' has-error'}}">
<span>Source Service</span>
<PowerSelectWithCreate
@options={{services}}
@searchField="Name"
@selected={{SourceName}}
@searchPlaceholder="Type service name"
@buildSuggestion={{action "createNewLabel" "Use a Consul Service called '{{term}}'"}}
@showCreateWhen={{action "isUnique" services}}
@onCreate={{action api.change "SourceName"}}
@onChange={{action api.change "SourceName"}} as |service|>
{{#if (eq service.Name '*') }}
* (All Services)
{{else}}
{{service.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>Search for an existing service, or enter any Service name.</em>
</label>
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
<label data-test-source-nspace class="type-select{{if api.data.error.SourceNS ' has-error'}}">
<span>Source Namespace</span>
<PowerSelectWithCreate
@options={{nspaces}}
@searchField="Name"
@selected={{SourceNS}}
@searchPlaceholder="Type namespace name"
@buildSuggestion={{action "createNewLabel" "Use a Consul Namespace called '{{term}}'"}}
@showCreateWhen={{action "isUnique" nspaces}}
@onCreate={{action api.change "SourceNS"}}
@onChange={{action api.change "SourceNS"}} as |nspace|>
{{#if (eq nspace.Name '*') }}
* (All Namespaces)
{{else}}
{{nspace.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>Search for an existing namespace, or enter any Namespace name.</em>
</label>
{{/if}}
</fieldset>
<fieldset>
<h2>Destination</h2>
<label data-test-destination-element class="type-select{{if api.data.error.DestinationName ' has-error'}}">
<span>Destination Service</span>
<PowerSelectWithCreate
@options={{services}}
@searchField="Name"
@selected={{DestinationName}}
@searchPlaceholder="Type service name"
@buildSuggestion={{action "createNewLabel" "Use a Consul Service called '{{term}}'"}}
@showCreateWhen={{action "isUnique" services}}
@onCreate={{action api.change "DestinationName"}}
@onChange={{action api.change "DestinationName"}} as |service|>
{{#if (eq service.Name '*') }}
* (All Services)
{{else}}
{{service.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>Search for an existing service, or enter any Service name.</em>
</label>
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
<label data-test-destination-nspace class="type-select{{if api.data.error.DestinationNS ' has-error'}}">
<span>Destination Namespace</span>
<PowerSelectWithCreate
@options={{nspaces}}
@searchField="Name"
@selected={{DestinationNS}}
@searchPlaceholder="Type namespace name"
@buildSuggestion={{action "createNewLabel" "Use a future Consul Namespace called '{{term}}'"}}
@showCreateWhen={{action "isUnique" nspaces}}
@onCreate={{action api.change "DestinationNS"}}
@onChange={{action api.change "DestinationNS"}} as |nspace|>
{{#if (eq nspace.Name '*') }}
* (All Namespaces)
{{else}}
{{nspace.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>For the destination, you may choose any namespace for which you have access.</em>
</label>
{{/if}}
</fieldset>
</div>
<div role="radiogroup" class={{if api.data.error.Action ' has-error'}}>
{{#each (array 'allow' 'deny') as |intent|}}
<label>
<span>{{capitalize intent}}</span>
<input type="radio" name="Action" value={{intent}} checked={{if (eq api.data.Action intent) 'checked'}} onchange={{action api.change}}/>
</label>
{{/each}}
</div>
<label class="type-text{{if api.data.error.Description ' has-error'}}">
<span>Description (Optional)</span>
<input type="text" name="Description" value={{api.data.Description}} placeholder="Description (Optional)" onchange={{action api.change}} />
</label>
</fieldset>
<div>
<button type="submit" disabled={{or api.data.isInvalid api.disabled}}>Save</button>
{{#if (not api.isCreate)}}
<button type="reset" onclick={{action oncancel api.data}} disabled={{api.disabled}}>Cancel</button>
{{#if (not-eq form.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>
</BlockSlot>
<BlockSlot @name="dialog" as |execute cancel message|>
<DeleteConfirmation @message={{message}} @execute={{execute}} @cancel={{cancel}} />
</BlockSlot>
</ConfirmationDialog>
{{/if}}
{{/if}}
</div>
</form>
</BlockSlot>
</DataForm>

View File

@ -1,71 +1,76 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { setProperties, set, get } from '@ember/object';
import { assert } from '@ember/debug';
export default Component.extend({
tagName: '',
dom: service('dom'),
builder: service('form'),
init: function() {
this._super(...arguments);
this.form = this.builder.form('intention');
ondelete: function() {
this.onsubmit(...arguments);
},
didReceiveAttrs: function() {
this._super(...arguments);
if (this.item && this.services && this.nspaces) {
let services = this.services || [];
let nspaces = this.nspaces || [];
let source = services.findBy('Name', this.item.SourceName);
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: this.item.SourceName };
services = [source].concat(services);
source = { Name: item.SourceName };
items = [source].concat(items);
}
let destination = services.findBy('Name', this.item.DestinationName);
let destination = items.findBy('Name', item.DestinationName);
if (!destination) {
destination = { Name: this.item.DestinationName };
services = [destination].concat(services);
destination = { Name: item.DestinationName };
items = [destination].concat(items);
}
let sourceNS = nspaces.findBy('Name', this.item.SourceNS);
if (!sourceNS) {
sourceNS = { Name: this.item.SourceNS };
nspaces = [sourceNS].concat(nspaces);
}
let destinationNS = this.nspaces.findBy('Name', this.item.DestinationNS);
if (!destinationNS) {
destinationNS = { Name: this.item.DestinationNS };
nspaces = [destinationNS].concat(nspaces);
}
// TODO: Use this.{item,services} when we have this.args
setProperties(this, {
_item: this.form.setData(this.item).getData(),
_services: services,
_nspaces: nspaces,
services: items,
SourceName: source,
DestinationName: destination,
SourceNS: sourceNS,
DestinationNS: destinationNS,
});
} else {
assert('@item, @services and @nspaces are required arguments', false);
}
},
actions: {
},
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,
});
},
createNewLabel: function(template, term) {
return template.replace(/{{term}}/g, term);
},
isUnique: function(term) {
return !this._services.findBy('Name', term);
isUnique: function(items, term) {
return !items.findBy('Name', term);
},
submit: function(item, e) {
e.preventDefault();
this.onsubmit(...arguments);
},
change: function(e, value, item) {
const event = this.dom.normalizeEvent(e, value);
const form = this.form;
const target = event.target;
change: function(e, form, item) {
const target = e.target;
let name, selected, match;
switch (target.name) {
@ -88,7 +93,7 @@ export default Component.extend({
// basically the difference between
// `item.DestinationName` and just `DestinationName`
// see if the name is already in the list
match = this._services.filterBy('Name', name);
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
@ -96,18 +101,18 @@ export default Component.extend({
switch (target.name) {
case 'SourceName':
case 'DestinationName':
set(this, '_services', [selected].concat(this._services.toArray()));
set(this, 'services', [selected].concat(this.services.toArray()));
break;
case 'SourceNS':
case 'DestinationNS':
set(this, '_nspaces', [selected].concat(this._nspaces.toArray()));
set(this, 'nspaces', [selected].concat(this.nspaces.toArray()));
break;
}
}
set(this, target.name, selected);
break;
}
form.handleEvent(event);
form.handleEvent(e);
},
},
});

View File

@ -1,73 +1,87 @@
<TabularCollection class="consul-intention-list" @items={{items}} as |item index|>
<BlockSlot @name="header">
<th>Source</th>
<th>&nbsp;</th>
<th>Destination</th>
<th>Precedence</th>
<DataWriter
@sink={{concat '/' dc '/' nspace '/intention/'}}
@type="intention"
@ondelete={{action ondelete}}
as |writer|>
<BlockSlot @name="content">
{{#if (gt items.length 0)}}
<TabularCollection class="consul-intention-list" @items={{items}} as |item index|>
<BlockSlot @name="header">
<th>Source</th>
<th>&nbsp;</th>
<th>Destination</th>
<th>Precedence</th>
</BlockSlot>
<BlockSlot @name="row">
<td class="source" data-test-intention={{item.ID}}>
<a href={{href-to (or routeName 'dc.intentions.edit') item.ID}} data-test-intention-source={{item.SourceName}}>
{{#if (eq item.SourceName '*') }}
All Services (*)
{{else}}
{{item.SourceName}}
{{/if}}
{{! TODO: slugify }}
<em class={{concat 'nspace-' (or item.SourceNS 'default')}}>{{or item.SourceNS 'default'}}</em>
</a>
</td>
<td class="intent-{{item.Action}}" data-test-intention-action="{{item.Action}}">
<strong>{{item.Action}}</strong>
</td>
<td class="destination" data-test-intention-destination="{{item.DestinationName}}">
<span>
{{#if (eq item.DestinationName '*') }}
All Services (*)
{{else}}
{{item.DestinationName}}
{{/if}}
{{! TODO: slugify }}
<em class={{concat 'nspace-' (or item.DestinationNS 'default')}}>{{or item.DestinationNS 'default'}}</em>
</span>
</td>
<td class="precedence">
{{item.Precedence}}
</td>
</BlockSlot>
<BlockSlot @name="actions" as |index change checked|>
<PopoverMenu @expanded={{if (eq checked index) true false}} @onchange={{action change index}} @keyboardAccess={{false}}>
<BlockSlot @name="trigger">
More
</BlockSlot>
<BlockSlot @name="menu" as |confirm send keypressClick change|>
<li role="none">
<a role="menuitem" tabindex="-1" href={{href-to (or routeName 'dc.intentions.edit') item.ID}}>Edit</a>
</li>
<li role="none" class="dangerous">
<label for={{confirm}} role="menuitem" tabindex="-1" onkeypress={{keypressClick}} data-test-delete>Delete</label>
<div role="menu">
<div class="confirmation-alert warning">
<div>
<header>
Confirm Delete
</header>
<p>
Are you sure you want to delete this intention?
</p>
</div>
<ul>
<li class="dangerous">
<button tabindex="-1" type="button" class="type-delete" onclick={{queue (action change) (action writer.delete item)}}>Delete</button>
</li>
<li>
<label for={{confirm}}>Cancel</label>
</li>
</ul>
</div>
</div>
</li>
</BlockSlot>
</PopoverMenu>
</BlockSlot>
</TabularCollection>
{{else}}
{{yield}}
{{/if}}
</BlockSlot>
<BlockSlot @name="row">
<td class="source" data-test-intention={{item.ID}}>
<a href={{href-to 'dc.intentions.edit' item.ID}} data-test-intention-source={{item.SourceName}}>
{{#if (eq item.SourceName '*') }}
All Services (*)
{{else}}
{{item.SourceName}}
{{/if}}
{{! TODO: slugify }}
<em class={{concat 'nspace-' (or item.SourceNS 'default')}}>{{or item.SourceNS 'default'}}</em>
</a>
</td>
<td class="intent-{{item.Action}}" data-test-intention-action="{{item.Action}}">
<strong>{{item.Action}}</strong>
</td>
<td class="destination" data-test-intention-destination="{{item.DestinationName}}">
<span>
{{#if (eq item.DestinationName '*') }}
All Services (*)
{{else}}
{{item.DestinationName}}
{{/if}}
{{! TODO: slugify }}
<em class={{concat 'nspace-' (or item.DestinationNS 'default')}}>{{or item.DestinationNS 'default'}}</em>
</span>
</td>
<td class="precedence">
{{item.Precedence}}
</td>
</BlockSlot>
<BlockSlot @name="actions" as |index change checked|>
<PopoverMenu @expanded={{if (eq checked index) true false}} @onchange={{action change index}} @keyboardAccess={{false}}>
<BlockSlot @name="trigger">
More
</BlockSlot>
<BlockSlot @name="menu" as |confirm send keypressClick change|>
<li role="none">
<a role="menuitem" tabindex="-1" href={{href-to 'dc.intentions.edit' item.ID}}>Edit</a>
</li>
<li role="none" class="dangerous">
<label for={{confirm}} role="menuitem" tabindex="-1" onkeypress={{keypressClick}} data-test-delete>Delete</label>
<div role="menu">
<div class="confirmation-alert warning">
<div>
<header>
Confirm Delete
</header>
<p>
Are you sure you want to delete this intention?
</p>
</div>
<ul>
<li class="dangerous">
<button tabindex="-1" type="button" class="type-delete" onclick={{queue (action change) (action ondelete item)}}>Delete</button>
</li>
<li>
<label for={{confirm}}>Cancel</label>
</li>
</ul>
</div>
</div>
</li>
</BlockSlot>
</PopoverMenu>
</BlockSlot>
</TabularCollection>
</DataWriter>

View File

@ -2,4 +2,5 @@ import Component from '@ember/component';
export default Component.extend({
tagName: '',
ondelete: function() {},
});

View File

@ -1,53 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="44px" height="44px" viewBox="0 0 44 44" version="1.1">
<g>
<circle r="1" cx="27" cy="2" style="transform-origin: 27px 2px" />
<circle r="1" cx="17" cy="2" style="transform-origin: 17px 2px" />
<circle r="1" cx="27" cy="42" style="transform-origin: 27px 42px" />
<circle r="1" cx="17" cy="42" style="transform-origin: 17px 42px" />
<circle r="1" cx="2" cy="17" style="transform-origin: 2px 17px" />
<circle r="1" cx="2" cy="27" style="transform-origin: 2px 27px" />
<circle r="1" cx="42" cy="17" style="transform-origin: 42px 17px" />
<circle r="1" cx="42" cy="27" style="transform-origin: 42px 27px" />
<circle r="1" cx="33" cy="4" style="transform-origin: 33px 4px" />
<circle r="1" cx="11" cy="4" style="transform-origin: 11px 4px" />
<circle r="1" cx="33" cy="40" style="transform-origin: 33px 40px" />
<circle r="1" cx="11" cy="40" style="transform-origin: 11px 40px" />
<circle r="1" cx="40" cy="11" style="transform-origin: 40px 11px" />
<circle r="1" cx="4" cy="33" style="transform-origin: 4px 33px" />
<circle r="1" cx="40" cy="33" style="transform-origin: 40px 33px" />
<circle r="1" cx="4" cy="11" style="transform-origin: 4px 11px" />
</g>
<g>
<circle r="2" cx="22" cy="4" style="transform-origin: 22px 4px" />
<circle r="2" cx="22" cy="40" style="transform-origin: 22px 40px" />
<circle r="2" cx="4" cy="22" style="transform-origin: 4px 22px" />
<circle r="2" cx="40" cy="22" style="transform-origin: 40px 22px" />
<circle r="2" cx="9" cy="9" style="transform-origin: 9px 9px" />
<circle r="2" cx="35" cy="35" style="transform-origin: 35px 35px" />
<circle r="2" cx="35" cy="9" style="transform-origin: 35px 9px" />
<circle r="2" cx="9" cy="35" style="transform-origin: 9px 35px" />
</g>
<g>
<circle r="2" cx="28" cy="8" style="transform-origin: 28px 8px" />
<circle r="2" cx="16" cy="8" style="transform-origin: 16px 8px" />
<circle r="2" cx="28" cy="36" style="transform-origin: 28px 36px" />
<circle r="2" cx="16" cy="36" style="transform-origin: 16px 36px" />
<circle r="2" cx="8" cy="28" style="transform-origin: 8px 28px" />
<circle r="2" cx="8" cy="16" style="transform-origin: 8px 16px" />
<circle r="2" cx="36" cy="28" style="transform-origin: 36px 28px" />
<circle r="2" cx="36" cy="16" style="transform-origin: 36px 16px" />
</g>
<g>
<circle r="5" cx="22" cy="12" style="transform-origin: 22px 12px" />
<circle r="5" cx="22" cy="32" style="transform-origin: 22px 32px" />
<circle r="5" cx="12" cy="22" style="transform-origin: 12px 22px" />
<circle r="5" cx="32" cy="22" style="transform-origin: 32px 22px" />
<circle r="5" cx="15" cy="15" style="transform-origin: 15px 15px" />
<circle r="5" cx="29" cy="29" style="transform-origin: 29px 29px" />
<circle r="5" cx="29" cy="15" style="transform-origin: 29px 15px" />
<circle r="5" cx="15" cy="29" style="transform-origin: 15px 29px" />
</g>
<g>
<circle r="9" cx="22" cy="22" style="transform-origin: 22px 22px" />
</g>
</svg>
<div class="consul-loader" ...attributes>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="44px" height="44px" viewBox="0 0 44 44" version="1.1">
<g>
<circle r="1" cx="27" cy="2" style="transform-origin: 27px 2px" />
<circle r="1" cx="17" cy="2" style="transform-origin: 17px 2px" />
<circle r="1" cx="27" cy="42" style="transform-origin: 27px 42px" />
<circle r="1" cx="17" cy="42" style="transform-origin: 17px 42px" />
<circle r="1" cx="2" cy="17" style="transform-origin: 2px 17px" />
<circle r="1" cx="2" cy="27" style="transform-origin: 2px 27px" />
<circle r="1" cx="42" cy="17" style="transform-origin: 42px 17px" />
<circle r="1" cx="42" cy="27" style="transform-origin: 42px 27px" />
<circle r="1" cx="33" cy="4" style="transform-origin: 33px 4px" />
<circle r="1" cx="11" cy="4" style="transform-origin: 11px 4px" />
<circle r="1" cx="33" cy="40" style="transform-origin: 33px 40px" />
<circle r="1" cx="11" cy="40" style="transform-origin: 11px 40px" />
<circle r="1" cx="40" cy="11" style="transform-origin: 40px 11px" />
<circle r="1" cx="4" cy="33" style="transform-origin: 4px 33px" />
<circle r="1" cx="40" cy="33" style="transform-origin: 40px 33px" />
<circle r="1" cx="4" cy="11" style="transform-origin: 4px 11px" />
</g>
<g>
<circle r="2" cx="22" cy="4" style="transform-origin: 22px 4px" />
<circle r="2" cx="22" cy="40" style="transform-origin: 22px 40px" />
<circle r="2" cx="4" cy="22" style="transform-origin: 4px 22px" />
<circle r="2" cx="40" cy="22" style="transform-origin: 40px 22px" />
<circle r="2" cx="9" cy="9" style="transform-origin: 9px 9px" />
<circle r="2" cx="35" cy="35" style="transform-origin: 35px 35px" />
<circle r="2" cx="35" cy="9" style="transform-origin: 35px 9px" />
<circle r="2" cx="9" cy="35" style="transform-origin: 9px 35px" />
</g>
<g>
<circle r="2" cx="28" cy="8" style="transform-origin: 28px 8px" />
<circle r="2" cx="16" cy="8" style="transform-origin: 16px 8px" />
<circle r="2" cx="28" cy="36" style="transform-origin: 28px 36px" />
<circle r="2" cx="16" cy="36" style="transform-origin: 16px 36px" />
<circle r="2" cx="8" cy="28" style="transform-origin: 8px 28px" />
<circle r="2" cx="8" cy="16" style="transform-origin: 8px 16px" />
<circle r="2" cx="36" cy="28" style="transform-origin: 36px 28px" />
<circle r="2" cx="36" cy="16" style="transform-origin: 36px 16px" />
</g>
<g>
<circle r="5" cx="22" cy="12" style="transform-origin: 22px 12px" />
<circle r="5" cx="22" cy="32" style="transform-origin: 22px 32px" />
<circle r="5" cx="12" cy="22" style="transform-origin: 12px 22px" />
<circle r="5" cx="32" cy="22" style="transform-origin: 32px 22px" />
<circle r="5" cx="15" cy="15" style="transform-origin: 15px 15px" />
<circle r="5" cx="29" cy="29" style="transform-origin: 29px 29px" />
<circle r="5" cx="29" cy="15" style="transform-origin: 29px 15px" />
<circle r="5" cx="15" cy="29" style="transform-origin: 15px 29px" />
</g>
<g>
<circle r="9" cx="22" cy="22" style="transform-origin: 22px 22px" />
</g>
</svg>
</div>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,40 @@
<DataLoader @items={{item}} @src={{concat '/' nspace '/' dc '/' type '/' src}} @onchange={{action "setData"}} @once={{true}}>
<BlockSlot @name="loaded">
<DataWriter
@sink={{concat '/' nspace '/' (or data.Datacenter dc) '/' type '/'}}
@type={{type}}
@ondelete={{action ondelete}}
@onchange={{action onsubmit}}
as |writer|>
{{#let (hash
data=data
change=(action "change")
isCreate=create
error=writer.error
disabled=writer.inflight
submit=(action writer.persist data)
delete=(action writer.delete data)
) as |api|}}
{{yield api}}
<BlockSlot @name="error">
<YieldSlot @name="error">
{{yield api}}
</YieldSlot>
</BlockSlot>
<BlockSlot @name="content">
<YieldSlot @name="form">
{{yield api}}
</YieldSlot>
</BlockSlot>
{{/let}}
</DataWriter>
</BlockSlot>
</DataLoader>

View File

@ -0,0 +1,59 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { set, get } from '@ember/object';
import Slotted from 'block-slots';
export default Component.extend(Slotted, {
tagName: '',
dom: service('dom'),
builder: service('form'),
create: false,
ondelete: function() {
return this.onsubmit(...arguments);
},
oncancel: function() {
return this.onsubmit(...arguments);
},
onsubmit: function() {},
onchange: function(e, form) {
return form.handleEvent(e);
},
didReceiveAttrs: function() {
this._super(...arguments);
try {
this.form = this.builder.form(this.type);
} catch (e) {
// passthrough
// this lets us load view only data that doesn't have a form
}
},
willDestroyElement: function() {
this._super(...arguments);
if (get(this, 'data.isNew')) {
this.data.rollbackAttributes();
}
},
actions: {
setData: function(data) {
let changeset = data;
// convert to a real changeset
if (typeof this.form !== 'undefined') {
changeset = this.form.setData(data).getData();
}
// mark as creating
// and autofill the new record if required
if (get(changeset, 'isNew')) {
set(this, 'create', true);
changeset = Object.entries(this.autofill || {}).reduce(function(prev, [key, value]) {
set(prev, key, value);
return prev;
}, changeset);
}
set(this, 'data', changeset);
return this.data;
},
change: function(e, value, item) {
this.onchange(this.dom.normalizeEvent(e, value), this.form, this.form.getData());
},
},
});

View File

@ -0,0 +1,46 @@
export default {
id: 'data-loader',
initial: 'load',
on: {
ERROR: {
target: 'disconnected',
},
LOAD: [
{
target: 'idle',
cond: 'loaded',
},
{
target: 'loading',
},
],
},
states: {
load: {},
loading: {
on: {
SUCCESS: {
target: 'idle',
},
ERROR: {
target: 'error',
},
},
},
idle: {},
error: {
on: {
RETRY: {
target: 'load',
},
},
},
disconnected: {
on: {
RETRY: {
target: 'load',
},
},
},
},
};

View File

@ -0,0 +1,68 @@
{{yield}}
<StateChart @src={{chart}} as |State Guard Action dispatch state|>
<Ref @target={{this}} @name="dispatch" @value={{dispatch}} />
<Guard @name="loaded" @cond={{action "isLoaded"}} />
{{did-update (fn dispatch "LOAD") src=src}}
{{#let (hash
data=data
error=error
) as |api|}}
{{! if we didn't specify any data}}
{{#if (not items)}}
{{! try and load the data if we aren't in an error state}}
<State @notMatches={{array "error" "disconnected"}}>
{{! but only if we only asked for a single load and we are in loading state}}
{{#if (or (not once) (state-matches state "loading"))}}
<DataSource
@open={{open}}
@src={{src}}
@onchange={{queue (action "change" value="data") (action dispatch "SUCCESS")}}
@onerror={{queue (action (mut error) value="error.errors.firstObject") (action dispatch "ERROR")}}
/>
{{/if}}
</State>
{{/if}}
<State @matches="loading">
{{#yield-slot name="loading"}}
{{yield api}}
{{else}}
<ConsulLoader />
{{/yield-slot}}
</State>
<State @matches="error">
{{#yield-slot name="error"}}
{{yield api}}
{{else}}
<ErrorState @error={{error}} />
{{/yield-slot}}
</State>
<State @matches={{array "idle" "disconnected"}}>
<State @matches="disconnected">
{{#yield-slot name="disconnected" params=(block-params (component 'notification' after=(action dispatch "RESET")))}}
{{yield api}}
{{else}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="warning notification-update">
<strong>Warning!</strong>
An error was returned whilst loading this data, refresh to try again.
</p>
</Notification>
{{/yield-slot}}
</State>
<YieldSlot @name="loaded">
{{yield api}}
</YieldSlot>
</State>
{{/let}}
</StateChart>

View File

@ -0,0 +1,31 @@
import Component from '@ember/component';
import { set } from '@ember/object';
import Slotted from 'block-slots';
import chart from './chart.xstate';
export default Component.extend(Slotted, {
tagName: '',
onchange: data => data,
init: function() {
this._super(...arguments);
this.chart = chart;
},
didReceiveAttrs: function() {
this._super(...arguments);
if (typeof this.items !== 'undefined') {
this.actions.change.apply(this, [this.items]);
}
},
didInsertElement: function() {
this._super(...arguments);
this.dispatch('LOAD');
},
actions: {
isLoaded: function() {
return typeof this.items !== 'undefined';
},
change: function(data) {
set(this, 'data', this.onchange(data));
},
},
});

View File

@ -74,31 +74,35 @@ export default Component.extend({
},
didInsertElement: function() {
this._super(...arguments);
if (typeof this.data !== 'undefined') {
this.actions.open.apply(this, [this.data]);
if (typeof this.data !== 'undefined' || typeof this.item !== 'undefined') {
this.actions.open.apply(this, [this.data, this.item]);
}
},
persist: function(data, instance) {
set(this, 'instance', this.service.prepare(this.sink, data, instance));
if (typeof data !== 'undefined') {
set(this, 'instance', this.service.prepare(this.sink, data, instance));
} else {
set(this, 'instance', instance);
}
this.source(() => this.service.persist(this.sink, this.instance));
},
remove: function(instance) {
set(this, 'instance', this.service.prepare(this.sink, null, instance));
this.source(() => this.service.remove(this.sink, this.instance));
set(this, 'instance', instance);
this.source(() => this.service.remove(this.sink, instance));
},
actions: {
open: function(data, instance) {
if (instance instanceof Event) {
instance = undefined;
open: function(data, item) {
if (item instanceof Event) {
item = undefined;
}
if (typeof data === 'undefined') {
if (typeof data === 'undefined' && typeof item === 'undefined') {
throw new Error('You must specify data to save, or null to remove');
}
// potentially allow {} and "" as 'remove' flags
if (data === null || data === '') {
this.remove(instance);
this.remove(item);
} else {
this.persist(data, instance);
this.persist(data, item);
}
},
},

View File

@ -15,6 +15,7 @@
| --- | --- | --- | --- |
| `src` | `String` | | The source to subscribe to updates to, this should map to a string based URI |
| `loading` | `String` | eager | Allows the browser to defer loading offscreen DataSources (`eager\|lazy`). Setting to `lazy` only loads the data when the DataSource is visible in the DOM (inc. `display: none\|block;`) |
| `open` | `Boolean` | false | Force the DataSource to open, used to force non-blocking data to refresh (has no effect for blocking data) |
| `onchange` | `Function` | | The action to fire when the data changes. Emits an Event-like object with a `data` property containing the data. |
| `onerror` | `Function` | | The action to fire when an error occurs. Emits ErrorEvent object with an `error` property containing the Error. |

View File

@ -1,6 +1,6 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { set } from '@ember/object';
import { set, get } from '@ember/object';
import { schedule } from '@ember/runloop';
/**
@ -72,17 +72,22 @@ export default Component.extend({
this._lazyListeners.remove();
}
if (this.loading === 'eager' || this.isIntersecting) {
this.actions.open.bind(this)();
this.actions.open.apply(this, []);
}
},
actions: {
// keep this argumentless
open: function() {
// get a new source and replace the old one, cleaning up as we go
const source = replace(this, 'source', this.data.open(this.src, this), (prev, source) => {
// Makes sure any previous source (if different) is ALWAYS closed
this.data.close(prev, this);
});
const source = replace(
this,
'source',
this.data.open(this.src, this, this.open),
(prev, source) => {
// Makes sure any previous source (if different) is ALWAYS closed
this.data.close(prev, this);
}
);
const error = err => {
try {
this.onerror(err);
@ -100,7 +105,11 @@ export default Component.extend({
error(err);
}
},
error: e => error(e),
error: e => {
if (get(e, 'error.errors.firstObject.status') !== '429') {
error(e);
}
},
});
replace(this, '_remove', remove);
// dispatch the current data of the source if we have any

View File

@ -0,0 +1,57 @@
export default {
id: 'data-writer',
initial: 'idle',
states: {
idle: {
on: {
PERSIST: {
target: 'persisting',
},
REMOVE: {
target: 'removing',
},
},
},
removing: {
on: {
SUCCESS: {
target: 'removed',
},
ERROR: {
target: 'error',
},
},
},
persisting: {
on: {
SUCCESS: {
target: 'persisted',
},
ERROR: {
target: 'error',
},
},
},
removed: {
on: {
RESET: {
target: 'idle',
},
},
},
persisted: {
on: {
RESET: {
target: 'idle',
},
},
},
error: {
on: {
RESET: {
target: 'idle',
},
},
},
},
};

View File

@ -0,0 +1,80 @@
<StateChart @src={{chart}} as |State Guard Action dispatch state|>
<Ref @target={{this}} @name="dispatch" @value={{dispatch}} />
{{#let (hash
data=data
error=error
persist=(action "persist")
delete=(queue (action (mut data)) (action dispatch "REMOVE"))
inflight=(state-matches state (array "persisting" "removing"))
) as |api|}}
{{yield api}}
<State @matches="removing">
<DataSink
@sink={{sink}}
@item={{data}}
@data={{null}}
@onchange={{action dispatch "SUCCESS"}}
@onerror={{queue (action (mut error) value="error.errors.firstObject") (action dispatch "ERROR")}}
/>
</State>
<State @matches="persisting">
<DataSink
@sink={{sink}}
@item={{data}}
@onchange={{action dispatch "SUCCESS"}}
@onerror={{queue (action (mut error) value="error.errors.firstObject") (action dispatch "ERROR")}}
/>
</State>
<State @matches="removed">
<Notification @after={{queue (action dispatch "RESET") (action ondelete)}}>
{{#yield-slot name="removed"}}
{{yield api}}
{{else}}
<p data-notification role="alert" class="success notification-delete">
<strong>Success!</strong>
Your {{type}} has been deleted.
</p>
{{/yield-slot}}
</Notification>
</State>
<State @matches="persisted">
<Notification @after={{action onchange}}>
{{#yield-slot name="persisted"}}
{{yield api}}
{{else}}
<p data-notification role="alert" class="success notification-update">
<strong>Success!</strong>
Your {{type}} has been saved.
</p>
{{/yield-slot}}
</Notification>
</State>
<State @matches="error">
{{#yield-slot name="error" params=(block-params (component 'notification' after=(action dispatch "RESET")))}}
{{yield api}}
{{else}}
<Notification @after={{action dispatch "RESET"}}>
<p data-notification role="alert" class="error notification-update">
<strong>Error!</strong>
There was an error saving your {{type}}.
{{#if (and api.error.status api.error.detail)}}
<br />{{api.error.status}}: {{api.error.detail}}
{{/if}}
</p>
</Notification>
{{/yield-slot}}
</State>
<YieldSlot @name="content">
{{yield api}}
</YieldSlot>
{{/let}}
</StateChart>

View File

@ -0,0 +1,25 @@
import Component from '@ember/component';
import { set } from '@ember/object';
import Slotted from 'block-slots';
import chart from './chart.xstate';
export default Component.extend(Slotted, {
tagName: '',
ondelete: function() {
return this.onchange(...arguments);
},
onchange: function() {},
init: function() {
this._super(...arguments);
this.chart = chart;
},
actions: {
persist: function(data, e) {
if (e && typeof e.preventDefault === 'function') {
e.preventDefault();
}
set(this, 'data', data);
this.dispatch('PERSIST');
},
},
});

View File

@ -1,7 +1,7 @@
<p>
{{ message }}
{{message}}
</p>
<button type="button" class="type-delete" {{action execute}}>
<button type="button" class="type-delete" onclick={{action execute}}>
Confirm Delete
</button>
<button type="button" class="type-cancel" {{action cancel}}>Cancel</button>
<button type="button" class="type-cancel" onclick={{action cancel}}>Cancel</button>

View File

@ -0,0 +1,47 @@
{{#if (not-eq error.status "403")}}
<EmptyState class={{concat "status-" error.status}}>
<BlockSlot @name="header">
<h2>{{or error.message "Consul returned an error"}}</h2>
</BlockSlot>
{{#if error.status }}
<BlockSlot @name="subheader">
<h3 data-test-status={{error.status}}>Error {{error.status}}</h3>
</BlockSlot>
{{/if}}
<BlockSlot @name="body">
<p>
You may have visited a URL that is loading an unknown resource, so you can try going back to the root or try re-submitting your ACL Token/SecretID by going back to ACLs.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="back-link">
<a data-test-home rel="home" href={{href-to 'index'}}>Go back to root</a>
</li>
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}" rel="noopener noreferrer" target="_blank">Read the documentation</a>
</li>
</BlockSlot>
</EmptyState>
{{else}}
<EmptyState class="status-403">
<BlockSlot @name="header">
<h2 data-test-status={{error.status}}>You are not authorized</h2>
</BlockSlot>
<BlockSlot @name="subheader">
<h3>Error 403</h3>
</BlockSlot>
<BlockSlot @name="body">
<p>
You must be granted permissions to view this data. Ask your administrator if you think you should have access.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/acl/index.html" rel="noopener noreferrer" target="_blank">Read the documentation</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/security-networking/production-acls" rel="noopener noreferrer" target="_blank">Follow the guide</a>
</li>
</BlockSlot>
</EmptyState>
{{/if}}

View File

@ -0,0 +1,5 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
});

View File

@ -0,0 +1,3 @@
<div id={{guid}}>
{{yield}}
</div>

View File

@ -0,0 +1,34 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
export default Component.extend({
tagName: '',
notify: service('flashMessages'),
dom: service('dom'),
oncomplete: function() {},
init: function() {
this._super(...arguments);
this.guid = this.dom.guid(this);
},
didInsertElement: function() {
const $el = this.dom.element(`#${this.guid}`);
const options = {
timeout: 6000,
extendedTimeout: 300,
dom: $el.innerHTML,
};
if (this.sticky) {
options.sticky = true;
}
$el.remove();
this.notify.clearMessages();
if (typeof this.after === 'function') {
Promise.resolve(this.after()).then(res => {
this.notify.add(options);
});
} else {
this.notify.add(options);
}
},
});

View File

@ -12,6 +12,8 @@ export default Component.extend({
let match = true;
if (typeof this.matches !== 'undefined') {
match = this.service.matches(this.state, this.matches);
} else if (typeof this.notMatches !== 'undefined') {
match = !this.service.matches(this.state, this.notMatches);
}
set(this, 'rendering', match);
},

View File

@ -1,2 +0,0 @@
import Controller from './edit';
export default Controller.extend();

View File

@ -1,8 +0,0 @@
import Controller from '@ember/controller';
export default Controller.extend({
actions: {
route: function() {
this.send(...arguments);
},
},
});

View File

@ -9,9 +9,4 @@ export default Controller.extend({
replace: true,
},
},
actions: {
route: function() {
this.send(...arguments);
},
},
});

View File

@ -1,5 +1,4 @@
import Controller from '@ember/controller';
export default Controller.extend({
queryParams: {
filterBy: {
@ -10,9 +9,4 @@ export default Controller.extend({
replace: true,
},
},
actions: {
route: function() {
this.send(...arguments);
},
},
});

View File

@ -0,0 +1,14 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { getOwner } from '@ember/application';
export default Helper.extend({
router: service('router'),
compute(params, hash) {
return () => {
const container = getOwner(this);
const routeName = this.router.currentRoute.name;
return container.lookup(`route:${routeName}`).refresh();
};
},
});

View File

@ -1,74 +0,0 @@
import Route from '@ember/routing/route';
import { env } from 'consul-ui/env';
import { routes } from 'consul-ui/router';
import flat from 'flat';
let initialize = function() {};
Route.reopen(
['modelFor', 'transitionTo', 'replaceWith', 'paramsFor'].reduce(function(prev, item) {
prev[item] = function(routeName, ...rest) {
const isNspaced = this.routeName.startsWith('nspace.');
if (routeName === 'nspace') {
if (isNspaced || this.routeName === 'nspace') {
return this._super(...arguments);
} else {
return {
nspace: '~',
};
}
}
if (isNspaced && routeName.startsWith('dc')) {
return this._super(...[`nspace.${routeName}`, ...rest]);
}
return this._super(...arguments);
};
return prev;
}, {})
);
if (env('CONSUL_NSPACES_ENABLED')) {
const dotRe = /\./g;
initialize = function(container) {
const register = function(route, path) {
route.reopen({
templateName: path
.replace('/root-create', '/create')
.replace('/create', '/edit')
.replace('/folder', '/index'),
});
container.register(`route:nspace/${path}`, route);
const controller = container.resolveRegistration(`controller:${path}`);
if (controller) {
container.register(`controller:nspace/${path}`, controller);
}
};
const all = Object.keys(flat(routes))
.filter(function(item) {
return item.startsWith('dc');
})
.map(function(item) {
return item.replace('._options.path', '').replace(dotRe, '/');
});
all.forEach(function(item) {
let route = container.resolveRegistration(`route:${item}`);
let indexed;
// if the route doesn't exist it probably has an index route instead
if (!route) {
item = `${item}/index`;
route = container.resolveRegistration(`route:${item}`);
} else {
// if the route does exists
// then check to see if it also has an index route
indexed = `${item}/index`;
const index = container.resolveRegistration(`route:${indexed}`);
if (typeof index !== 'undefined') {
register(index, indexed);
}
}
register(route, item);
});
};
}
export default {
initialize,
};

View File

@ -1,11 +1,108 @@
import Route from '@ember/routing/route';
import { routes } from 'consul-ui/router';
import { env } from 'consul-ui/env';
import flat from 'flat';
const withNspace = function(currentRouteName, requestedRouteName, ...rest) {
const isNspaced = currentRouteName.startsWith('nspace.');
if (isNspaced && requestedRouteName.startsWith('dc')) {
return [`nspace.${requestedRouteName}`, ...rest];
}
return [requestedRouteName, ...rest];
};
const register = function(container, route, path) {
route.reopen({
templateName: path
.replace('/root-create', '/create')
.replace('/create', '/edit')
.replace('/folder', '/index'),
});
container.register(`route:nspace/${path}`, route);
const controller = container.resolveRegistration(`controller:${path}`);
if (controller) {
container.register(`controller:nspace/${path}`, controller);
}
};
export function initialize(container) {
// patch Route routeName-like methods for navigation to support nspace relative routes
Route.reopen(
['transitionTo', 'replaceWith'].reduce(function(prev, item) {
prev[item] = function(requestedRouteName, ...rest) {
return this._super(...withNspace(this.routeName, requestedRouteName, ...rest));
};
return prev;
}, {})
);
// patch Route routeName-like methods for data to support nspace relative routes
Route.reopen(
['modelFor', 'paramsFor'].reduce(function(prev, item) {
prev[item] = function(requestedRouteName, ...rest) {
const isNspaced = this.routeName.startsWith('nspace.');
if (requestedRouteName === 'nspace' && !isNspaced && this.routeName !== 'nspace') {
return {
nspace: '~',
};
}
return this._super(...withNspace(this.routeName, requestedRouteName, ...rest));
};
return prev;
}, {})
);
// extend router service with a nspace aware router to support nspace relative routes
const nspacedRouter = container.resolveRegistration('service:router').extend({
transitionTo: function(requestedRouteName, ...rest) {
return this._super(...withNspace(this.currentRoute.name, requestedRouteName, ...rest));
},
replaceWith: function(requestedRouteName, ...rest) {
return this._super(...withNspace(this.currentRoute.name, requestedRouteName, ...rest));
},
urlFor: function(requestedRouteName, ...rest) {
return this._super(...withNspace(this.currentRoute.name, requestedRouteName, ...rest));
},
});
container.register('service:router', nspacedRouter);
if (env('CONSUL_NSPACES_ENABLED')) {
// enable the nspace repo
['dc', 'settings', 'dc.intentions.edit', 'dc.intentions.create'].forEach(function(item) {
container.inject(`route:${item}`, 'nspacesRepo', 'service:repository/nspace/enabled');
container.inject(`route:nspace.${item}`, 'nspacesRepo', 'service:repository/nspace/enabled');
});
container.inject('route:application', 'nspacesRepo', 'service:repository/nspace/enabled');
const dotRe = /\./g;
// register automatic 'index' routes and controllers that start with 'dc'
Object.keys(flat(routes))
.filter(function(item) {
return item.startsWith('dc');
})
.map(function(item) {
return item.replace('._options.path', '').replace(dotRe, '/');
})
.forEach(function(item) {
let route = container.resolveRegistration(`route:${item}`);
let indexed;
// if the route doesn't exist it probably has an index route instead
if (!route) {
item = `${item}/index`;
route = container.resolveRegistration(`route:${item}`);
} else {
// if the route does exist
// then check to see if it also has an index route
indexed = `${item}/index`;
const index = container.resolveRegistration(`route:${indexed}`);
if (typeof index !== 'undefined') {
register(container, index, indexed);
}
}
register(container, route, item);
});
// tell the view we have nspaces enabled
container
.lookup('service:dom')
.root()

View File

@ -1,39 +0,0 @@
import Mixin from '@ember/object/mixin';
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';
export default Mixin.create(WithBlockingActions, {
errorCreate: function(type, e) {
if (e && e.errors && e.errors[0]) {
const error = e.errors[0];
if (parseInt(error.status) === HTTP_INTERNAL_SERVER_ERROR) {
if (error.detail.indexOf('duplicate intention found:') === 0) {
return 'exists';
}
}
}
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

@ -8,12 +8,12 @@ export default Model.extend({
[SLUG_KEY]: attr('string'),
Description: attr('string'),
SourceNS: attr('string'),
SourceName: attr('string'),
DestinationName: attr('string'),
SourceName: attr('string', { defaultValue: '*' }),
DestinationName: attr('string', { defaultValue: '*' }),
DestinationNS: attr('string'),
Precedence: attr('number'),
SourceType: attr('string', { defaultValue: 'consul' }),
Action: attr('string', { defaultValue: 'deny' }),
Action: attr('string', { defaultValue: 'allow' }),
Meta: attr(),
SyncTime: attr('number'),
Datacenter: attr('string'),

View File

@ -18,6 +18,12 @@ export const routes = {
},
intentions: {
_options: { path: '/intentions' },
edit: {
_options: { path: '/:intention' },
},
create: {
_options: { path: '/create' },
},
},
services: {
_options: { path: '/services' },

View File

@ -1,12 +1,12 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { next } from '@ember/runloop';
import { get, set } from '@ember/object';
import WithBlockingActions from 'consul-ui/mixins/with-blocking-actions';
const removeLoading = function($from) {
return $from.classList.remove('ember-loading');
return $from.classList.remove('ember-loading', 'loading');
};
export default Route.extend(WithBlockingActions, {
dom: service('dom'),
@ -38,31 +38,16 @@ export default Route.extend(WithBlockingActions, {
},
actions: {
loading: function(transition, originRoute) {
const from = get(transition, 'from.name') || 'application';
const controller = this.controllerFor(from);
set(controller, 'loading', true);
const $root = this.dom.root();
let dc = null;
if (originRoute.routeName !== 'dc' && originRoute.routeName !== 'application') {
const app = this.modelFor('application');
const model = this.modelFor('dc') || { dc: { Name: null } };
dc = this.repo.getActive(model.dc.Name, app.dcs);
}
hash({
loading: !$root.classList.contains('ember-loading'),
dc: dc,
nspace: this.nspacesRepo.getActive(),
}).then(model => {
next(() => {
const controller = this.controllerFor('application');
controller.setProperties(model);
transition.promise.finally(function() {
removeLoading($root);
controller.setProperties({
loading: false,
dc: model.dc,
});
});
});
$root.classList.add('loading');
transition.promise.finally(() => {
set(controller, 'loading', false);
removeLoading($root);
});
return true;
},
error: function(e, transition) {
// TODO: Normalize all this better

View File

@ -1,45 +1,5 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithIntentionActions from 'consul-ui/mixins/intention/with-actions';
import Route from './edit';
// TODO: This route and the edit Route need merging somehow
export default Route.extend(WithIntentionActions, {
export default Route.extend({
templateName: 'dc/intentions/edit',
repo: service('repository/intention'),
servicesRepo: service('repository/service'),
nspacesRepo: service('repository/nspace/disabled'),
model: function(params) {
const dc = this.modelFor('dc').dc.Name;
const nspace = '*';
this.item = this.repo.create({
Datacenter: dc,
});
return hash({
create: true,
isLoading: false,
item: this.item,
services: this.servicesRepo.findAllByDatacenter(dc, nspace),
nspaces: this.nspacesRepo.findAll(),
}).then(function(model) {
return {
...model,
...{
services: [{ Name: '*' }].concat(
model.services.toArray().filter(item => get(item, 'Kind') !== 'connect-proxy')
),
nspaces: [{ Name: '*' }].concat(model.nspaces.toArray()),
},
};
});
},
setupController: function(controller, model) {
controller.setProperties(model);
},
deactivate: function() {
if (get(this.item, 'isNew')) {
this.item.rollbackAttributes();
}
},
});

View File

@ -1,49 +1,18 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithIntentionActions from 'consul-ui/mixins/intention/with-actions';
// TODO: This route and the create Route need merging somehow
export default Route.extend(WithIntentionActions, {
export default Route.extend({
repo: service('repository/intention'),
servicesRepo: service('repository/service'),
nspacesRepo: service('repository/nspace/disabled'),
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;
// 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
// you may want to add it after you've added the intention
const nspace = '*';
return hash({
isLoading: false,
item: this.repo.findBySlug(params.id, dc, nspace),
services: this.servicesRepo.findAllByDatacenter(dc, nspace),
nspaces: this.nspacesRepo.findAll(),
history: this.history,
}).then(function(model) {
return {
...model,
...{
services: [{ Name: '*' }].concat(
model.services.toArray().filter(item => get(item, 'Kind') !== 'connect-proxy')
),
nspaces: [{ Name: '*' }].concat(model.nspaces.toArray()),
},
};
dc: dc,
nspace: nspace,
item:
typeof params.id !== 'undefined' ? this.repo.findBySlug(params.id, dc, nspace) : undefined,
});
},
setupController: function(controller, model) {

View File

@ -1,11 +1,6 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import WithIntentionActions from 'consul-ui/mixins/intention/with-actions';
export default Route.extend(WithIntentionActions, {
repo: service('repository/intention'),
export default Route.extend({
queryParams: {
filterBy: {
as: 'action',
@ -16,12 +11,10 @@ export default Route.extend(WithIntentionActions, {
},
},
model: function(params) {
return hash({
items: this.repo.findAllByDatacenter(
this.modelFor('dc').dc.Name,
this.modelFor('nspace').nspace.substr(1)
),
});
return {
dc: this.modelFor('dc').dc.Name,
nspace: this.modelFor('nspace').nspace.substr(1) || 'default',
};
},
setupController: function(controller, model) {
controller.setProperties(model);

View File

@ -5,7 +5,6 @@ import { get } from '@ember/object';
export default Route.extend({
repo: service('repository/service'),
intentionRepo: service('repository/intention'),
chainRepo: service('repository/discovery-chain'),
proxyRepo: service('repository/proxy'),
settings: service('settings'),
@ -13,6 +12,7 @@ export default Route.extend({
const dc = this.modelFor('dc').dc.Name;
const nspace = this.modelFor('nspace').nspace.substr(1);
return hash({
slug: params.name,
dc: dc,
nspace: nspace || 'default',
item: this.repo.findBySlug(params.name, dc, nspace),
@ -25,11 +25,6 @@ export default Route.extend({
)
? model
: hash({
intentions: this.intentionRepo
.findByService(params.name, dc, nspace)
.catch(function() {
return null;
}),
chain: this.chainRepo.findBySlug(params.name, dc, nspace),
proxies: this.proxyRepo.findAllBySlug(params.name, dc, nspace),
...model,

View File

@ -1,27 +1,3 @@
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(WithIntentionActions, {
queryParams: {
search: {
as: 'filter',
replace: true,
},
},
repo: service('repository/intention'),
model: function() {
const parent = this.routeName
.split('.')
.slice(0, -1)
.join('.');
return this.modelFor(parent);
},
setupController: function(controller, model) {
controller.setProperties(model);
},
// Overwrite default afterDelete action to just refresh
afterDelete: function() {
return this.refresh();
},
});
export default Route.extend();

View File

@ -0,0 +1,5 @@
import Route from './edit';
export default Route.extend({
templateName: 'dc/services/show/intentions/edit',
});

View File

@ -0,0 +1,15 @@
import Route from '@ember/routing/route';
export default Route.extend({
model: function(params, transition) {
return {
nspace: '*',
dc: this.paramsFor('dc').dc,
service: this.paramsFor('dc.services.show').name,
src: params.intention,
};
},
setupController: function(controller, model) {
controller.setProperties(model);
},
});

View File

@ -0,0 +1,20 @@
import Route from '@ember/routing/route';
export default Route.extend({
queryParams: {
search: {
as: 'filter',
replace: true,
},
},
model: function(params) {
return {
dc: this.modelFor('dc').dc.Name,
nspace: this.modelFor('nspace').nspace.substr(1) || 'default',
slug: this.paramsFor('dc.services.show').name,
};
},
setupController: function(controller, model) {
controller.setProperties(model);
},
});

View File

@ -5,27 +5,15 @@ export default Service.extend({
settings: service('settings'),
intention: service('repository/intention'),
prepare: function(sink, data, instance) {
const [, dc, nspace, model, slug] = sink.split('/');
const repo = this[model];
if (slug === '') {
instance = repo.create({
Datacenter: dc,
Namespace: nspace,
});
} else {
if (typeof instance === 'undefined') {
instance = repo.peek(slug);
}
}
return setProperties(instance, data);
},
persist: function(sink, instance) {
const [, , , /*dc*/ /*nspace*/ model] = sink.split('/');
const [, , , model] = sink.split('/');
const repo = this[model];
return repo.persist(instance);
},
remove: function(sink, instance) {
const [, , , /*dc*/ /*nspace*/ model] = sink.split('/');
const [, , , model] = sink.split('/');
const repo = this[model];
return repo.remove(instance);
},

View File

@ -1,10 +1,13 @@
import Service, { inject as service } from '@ember/service';
const parts = function(uri) {
if (uri.indexOf('://') === -1) {
uri = `consul://${uri}`;
}
return uri.split('://');
};
export default Service.extend({
data: service('data-sink/protocols/http'),
consul: service('data-sink/protocols/http'),
settings: service('data-sink/protocols/local-storage'),
prepare: function(uri, data, assign) {

View File

@ -3,11 +3,16 @@ import { get } from '@ember/object';
export default Service.extend({
datacenters: service('repository/dc'),
services: service('repository/service'),
namespaces: service('repository/nspace'),
intentions: service('repository/intention'),
intention: service('repository/intention'),
kv: service('repository/kv'),
token: service('repository/token'),
policies: service('repository/policy'),
policy: service('repository/policy'),
roles: service('repository/role'),
oidc: service('repository/oidc-provider'),
type: service('data-source/protocols/http/blocking'),
source: function(src, configuration) {
@ -33,14 +38,13 @@ export default Service.extend({
let method, slug;
switch (model) {
case 'datacenters':
find = configuration => repo.findAll(configuration);
break;
case 'namespaces':
find = configuration => repo.findAll(configuration);
break;
case 'token':
find = configuration => repo.self(rest[1], dc);
break;
case 'services':
case 'roles':
case 'policies':
find = configuration => repo.findAllByDatacenter(dc, nspace, configuration);
@ -48,6 +52,28 @@ export default Service.extend({
case 'policy':
find = configuration => repo.findBySlug(rest[0], dc, nspace, configuration);
break;
case 'intentions':
[method, ...slug] = rest;
switch (method) {
case 'for-service':
// TODO: Are we going to need to encode/decode here...?
find = configuration => repo.findByService(slug.join('/'), dc, nspace, configuration);
break;
default:
find = configuration => repo.findAllByDatacenter(dc, nspace, configuration);
break;
}
break;
case 'intention':
// TODO: Are we going to need to encode/decode here...?
slug = rest.join('/');
if (slug) {
find = configuration => repo.findBySlug(slug, dc, nspace, configuration);
} else {
find = configuration =>
Promise.resolve(repo.create({ Datacenter: dc, Namespace: nspace }));
}
break;
case 'oidc':
[method, ...slug] = rest;
switch (method) {

View File

@ -36,7 +36,7 @@ export default Service.extend({
usage = null;
},
open: function(uri, ref) {
open: function(uri, ref, open = false) {
let source;
// Check the cache for an EventSource that is already being used
// for this uri. If we don't have one, set one up.
@ -61,21 +61,30 @@ export default Service.extend({
// only cache data if we have any
if (typeof event !== 'undefined' && typeof cursor !== 'undefined') {
cache.set(uri, {
currentEvent: source.getCurrentEvent(),
cursor: source.configuration.cursor,
currentEvent: event,
cursor: cursor,
});
}
// the data is cached delete the EventSource
sources.delete(uri);
if (!usage.has(source)) {
sources.delete(uri);
}
},
});
sources.set(uri, source);
} else {
source = sources.get(uri);
}
// only open if its not already being used
// in the case of blocking queries being disabled
// you may want to specifically force an open
// if blocking queries are enabled then opening an already
// open blocking query does nothing
if (!usage.has(source) || open) {
source.open();
}
// set/increase the usage counter
usage.set(source, ref);
source.open();
return source;
},
close: function(source, ref) {

View File

@ -32,7 +32,7 @@ export default Service.extend({
}
}
const date = get(item, 'SyncTime');
if (typeof date !== 'undefined' && date != meta.date) {
if (!item.isDeleted && typeof date !== 'undefined' && date != meta.date) {
this.store.unloadRecord(item);
}
}

View File

@ -8,9 +8,15 @@ export default RepositoryService.extend({
getPrimaryKey: function() {
return PRIMARY_KEY;
},
create: function(obj) {
delete obj.Namespace;
return this._super(obj);
},
shouldReconcile: function(method) {
switch (method) {
case 'findByService':
// TODO: This is to be switched out for something at an adapter level
// so it works for both methods of interacting with data-sources
switch (true) {
case method === 'findByService' || method.indexOf('for-service') !== -1:
return false;
}
return this._super(...arguments);

View File

@ -13,6 +13,7 @@
@import 'core/layout';
@import 'routes/dc/settings/index';
@import 'routes/dc/services/index';
@import 'routes/dc/nodes/index';
@import 'routes/dc/intention/index';
@import 'routes/dc/kv/index';

View File

@ -52,7 +52,7 @@
padding: 1px 0 30px 0;
}
%app-view-content-empty {
margin-top: 0;
margin-top: 0 !important;
padding: 50px;
text-align: center;
}

View File

@ -23,7 +23,7 @@
%empty-state[class*='status-'] header::before {
@extend %as-pseudo;
}
%empty-state.status- header::before {
%empty-state header::before {
@extend %with-alert-circle-outline-mask;
}
%empty-state.status-404 header::before {
@ -32,9 +32,6 @@
%empty-state.status-403 header::before {
@extend %with-disabled-mask;
}
%empty-state[class*='status-5'] header::before {
@extend %with-alert-circle-outline-mask;
}
%empty-state li[class*='-link'] > *::after {
@extend %as-pseudo;
margin-left: 5px;

View File

@ -1,7 +1,13 @@
@import './loader/index';
html.template-loading main > div {
.consul-loader {
@extend %loader;
}
%loader circle {
fill: $magenta-100;
}
html:not(.loading) .view-loader {
display: none;
}
html.loading .app-view {
display: none;
}

View File

@ -3,5 +3,9 @@
display: flex;
align-items: center;
justify-content: center;
height: calc(100vh - 90px - 48px - 50px);
height: 100%;
position: absolute;
width: 100%;
top: 0;
margin-top: 0 !important;
}

View File

@ -0,0 +1,7 @@
@media #{$--horizontal-tabs} {
.template-service.template-show main header .actions {
position: relative;
top: 48px;
margin-top: 0;
}
}

View File

@ -10,14 +10,7 @@
@nspace={{or nspace nspaces.firstObject}}
@onchange={{action "reauthorize"}}
>
{{#if (not loading)}}
{{outlet}}
{{else}}
<AppView @class="loading show">
<BlockSlot @name="content">
<ConsulLoader />
</BlockSlot>
</AppView>
{{/if}}
<ConsulLoader class="view-loader" />
</HashicorpConsul>
{{/if}}

View File

@ -1,27 +0,0 @@
{{#if (eq type 'create')}}
{{#if (eq status 'success') }}
Your intention has been added.
{{else if (eq status 'exists') }}
An intention already exists for this Source-Destination pair. Please enter a different combination of Services, or search the intentions to edit an existing intention.
{{else}}
There was an error adding your intention.
{{/if}}
{{else if (eq type 'update') }}
{{#if (eq status 'success') }}
Your intention has been saved.
{{else if (eq status 'error')}}
There was an error saving your intention.
{{/if}}
{{ else if (eq type 'delete')}}
{{#if (eq status 'success') }}
Your intention was deleted.
{{else if (eq status 'error')}}
There was an error deleting 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

@ -1,23 +1,12 @@
{{#if item.ID }}
{{#if item.ID}}
{{title 'Edit Intention'}}
{{else}}
{{title 'New Intention'}}
{{/if}}
<AppView @class="intention edit" @loading={{isLoading}}>
<BlockSlot @name="notification" as |status type item error|>
{{partial 'dc/intentions/notifications'}}
</BlockSlot>
<AppView @class="intention edit">
<BlockSlot @name="breadcrumbs">
<ol>
{{#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>
</BlockSlot>
<BlockSlot @name="header">
@ -30,7 +19,7 @@
</h1>
</BlockSlot>
<BlockSlot @name="actions">
{{#if (not create) }}
{{#if item.ID}}
<CopyButton @value={{item.ID}} @name="UUID">
Copy UUID
</CopyButton>
@ -39,11 +28,9 @@
<BlockSlot @name="content">
<ConsulIntentionForm
@item={{item}}
@services={{uniq-by 'Name' services}}
@nspaces={{nspaces}}
@ondelete={{action "route" "delete"}}
@onsubmit={{action "route" (if item.isNew "create" "update")}}
@oncancel={{action "route" "cancel"}}
@dc={{dc}}
@nspace={{nspace}}
@onsubmit={{transition-to 'dc.intentions.index' dc}}
/>
</BlockSlot>
</AppView>

View File

@ -1,79 +1,89 @@
{{title 'Intentions'}}
<EventSource @src={{items}} />
{{#let (filter-by "Action" "deny" items) as |denied|}}
{{#let (selectable-key-values
(array "" (concat "All (" items.length ")"))
(array "allow" (concat "Allow (" (sub items.length denied.length) ")"))
(array "deny" (concat "Deny (" denied.length ")"))
selected=filterBy
)
as |filter|
}}
<AppView @class="intention list">
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/intentions/notifications'}}
</BlockSlot>
<BlockSlot @name="header">
<h1>
Intentions <em>{{format-number items.length}} total</em>
</h1>
<label for="toolbar-toggle"></label>
</BlockSlot>
<BlockSlot @name="actions">
<a data-test-create href="{{href-to 'dc.intentions.create'}}" class="type-create">Create</a>
</BlockSlot>
<BlockSlot @name="toolbar">
{{#if (gt items.length 0) }}
<SearchBar
data-test-intention-filter="true"
@value={{search}}
@onsearch={{action (mut search) value="target.value"}}
@selected={{filter.selected}}
@options={{filter.items}}
@onchange={{action (mut filterBy) value='target.value'}}
/>
{{/if}}
</BlockSlot>
<BlockSlot @name="content">
<ChangeableSet @dispatcher={{searchable 'intention' (if (eq filter.selected.key "") items (filter-by "Action" filter.selected.key items))}} @terms={{search}}>
<BlockSlot @name="set" as |filtered|>
<ConsulIntentionList
@items={{filtered}}
@ondelete={{action "route" "delete"}}
/>
<DataLoader @src={{concat '/' nspace '/' dc '/intentions'}} as |api|>
<BlockSlot @name="error">
<AppError @error={{api.error}} />
</BlockSlot>
<BlockSlot @name="loaded">
{{#let (filter-by "Action" "deny" api.data) as |denied|}}
{{#let (selectable-key-values
(array "" (concat "All (" api.data.length ")"))
(array "allow" (concat "Allow (" (sub api.data.length denied.length) ")"))
(array "deny" (concat "Deny (" denied.length ")"))
selected=filterBy
)
as |filter|
}}
<AppView @class="intention list">
<BlockSlot @name="header">
<h1>
Intentions <em>{{format-number api.data.length}} total</em>
</h1>
<label for="toolbar-toggle"></label>
</BlockSlot>
<BlockSlot @name="actions">
<a data-test-create href="{{href-to 'dc.intentions.create'}}" class="type-create">Create</a>
</BlockSlot>
<BlockSlot @name="toolbar">
{{#if (gt api.data.length 0) }}
<SearchBar
data-test-intention-filter="true"
@value={{search}}
@onsearch={{action (mut search) value="target.value"}}
@selected={{filter.selected}}
@options={{filter.items}}
@onchange={{action (mut filterBy) value='target.value'}}
/>
{{/if}}
</BlockSlot>
<BlockSlot @name="content">
<ChangeableSet @dispatcher={{searchable 'intention' (if (eq filter.selected.key "") api.data (filter-by "Action" filter.selected.key api.data))}} @terms={{search}}>
<BlockSlot @name="content" as |filtered|>
<ConsulIntentionList
@items={{filtered}}
@ondelete={{refresh-route}}
>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>
{{#if (gt api.data.length 0)}}
No intentions found
{{else}}
Welcome to Intentions
{{/if}}
</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
{{#if (gt api.data.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>
</ConsulIntentionList>
</BlockSlot>
</ChangeableSet>
</BlockSlot>
<BlockSlot @name="empty">
<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>
</BlockSlot>
</ChangeableSet>
</BlockSlot>
</AppView>
{{/let}}
{{/let}}
</AppView>
{{/let}}
{{/let}}
</BlockSlot>
</DataLoader>

View File

@ -5,7 +5,6 @@
<AppView @class="service show">
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/services/notifications'}}
{{partial 'dc/intentions/notifications'}}
</BlockSlot>
<BlockSlot @name="breadcrumbs">
<ol>
@ -31,7 +30,7 @@
(hash label="Upstreams" href=(href-to "dc.services.show.upstreams") selected=(is-href "dc.services.show.upstreams"))
'')
(hash label="Instances" href=(href-to "dc.services.show.instances") selected=(is-href "dc.services.show.instances"))
(if (not item.Service.Kind)
(if (not-eq item.Service.Kind 'terminating-gateway')
(hash label="Intentions" href=(href-to "dc.services.show.intentions") selected=(is-href "dc.services.show.intentions"))
'')
(if chain.Chain

View File

@ -1,49 +1 @@
{{#let (filter-by "Action" "deny" intentions) as |denied|}}
{{#let (selectable-key-values
(array "" (concat "All (" intentions.length ")"))
(array "allow" (concat "Allow (" (sub intentions.length denied.length) ")"))
(array "deny" (concat "Deny (" denied.length ")"))
selected=filterBy
)
as |filter|
}}
<div id="intentions" class="tab-section">
<div role="tabpanel">
{{#if (gt intentions.length 0) }}
<input type="checkbox" id="toolbar-toggle" />
<SearchBar
@value={{search}}
@onsearch={{action (mut search) value="target.value"}}
@selected={{filter.selected}}
@options={{filter.items}}
@onchange={{action (mut filterBy) value='target.value'}}
/>
{{/if}}
<ChangeableSet
@dispatcher={{
searchable
'intention'
(if (eq filter.selected.key "") intentions (filter-by "Action" filter.selected.key intentions))
}}
@terms={{search}}
>
<BlockSlot @name="set" as |filtered|>
<ConsulIntentionList
@items={{filtered}}
@ondelete={{action "route" "delete"}}
/>
</BlockSlot>
<BlockSlot @name="empty">
<EmptyState>
<BlockSlot @name="body">
<p>
There are no intentions for this service.
</p>
</BlockSlot>
</EmptyState>
</BlockSlot>
</ChangeableSet>
</div>
</div>
{{/let}}
{{/let}}
{{outlet}}

View File

@ -0,0 +1,9 @@
<ConsulIntentionForm
@nspace={{nspace}}
@dc={{dc}}
@src={{src}}
@autofill={{hash
SourceName=service
}}
@onsubmit={{transition-to 'dc.services.show.intentions.index'}}
/>

View File

@ -0,0 +1,59 @@
<DataLoader @src={{concat '/' nspace '/' dc '/intentions/for-service/' slug}} as |api|>
<BlockSlot @name="error">
<ErrorState @error={{api.error}} />
</BlockSlot>
<BlockSlot @name="loaded">
{{#let (filter-by "Action" "deny" api.data) as |denied|}}
{{#let (selectable-key-values
(array "" (concat "All (" api.data.length ")"))
(array "allow" (concat "Allow (" (sub api.data.length denied.length) ")"))
(array "deny" (concat "Deny (" denied.length ")"))
selected=filterBy
)
as |filter|
}}
<div id="intentions" class="tab-section">
<div role="tabpanel">
<Portal @target="app-view-actions">
<a data-test-create href={{href-to 'dc.services.show.intentions.create'}} class="type-create">Create</a>
</Portal>
{{#if (gt api.data.length 0) }}
<input type="checkbox" id="toolbar-toggle" />
<SearchBar
@value={{search}}
@onsearch={{action (mut search) value="target.value"}}
@selected={{filter.selected}}
@options={{filter.items}}
@onchange={{action (mut filterBy) value='target.value'}}
/>
{{/if}}
<ChangeableSet
@dispatcher={{
searchable
'intention'
(if (eq filter.selected.key "") api.data (filter-by "Action" filter.selected.key api.data))
}}
@terms={{search}}
>
<BlockSlot @name="content" as |filtered|>
<ConsulIntentionList
@items={{filtered}}
@ondelete={{refresh-route}}
@routeName="dc.services.show.intentions.edit"
>
<EmptyState>
<BlockSlot @name="body">
<p>
There are no intentions {{if (gt intentions.length 0) 'found '}} for this service.
</p>
</BlockSlot>
</EmptyState>
</ConsulIntentionList>
</BlockSlot>
</ChangeableSet>
</div>
</div>
{{/let}}
{{/let}}
</BlockSlot>
</DataLoader>

View File

@ -1,58 +1,3 @@
{{#if error}}
<AppView @class="error show">
<BlockSlot @name="header">
<h1>
Error {{error.status}}
</h1>
</BlockSlot>
<BlockSlot @name="content">
{{#if (not-eq error.status "403")}}
<EmptyState class={{concat "status-" error.status}}>
<BlockSlot @name="header">
<h2>{{or error.message "Consul returned an error"}}</h2>
</BlockSlot>
{{#if error.status }}
<BlockSlot @name="subheader">
<h3 data-test-status={{error.status}}>Error {{error.status}}</h3>
</BlockSlot>
{{/if}}
<BlockSlot @name="body">
<p>
You may have visited a URL that is loading an unknown resource, so you can try going back to the root or try re-submitting your ACL Token/SecretID by going back to ACLs.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="back-link">
<a data-test-home rel="home" href={{href-to 'index'}}>Go back to root</a>
</li>
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}" rel="noopener noreferrer" target="_blank">Read the documentation</a>
</li>
</BlockSlot>
</EmptyState>
{{else}}
<EmptyState class="status-403">
<BlockSlot @name="header">
<h2 data-test-status={{error.status}}>You are not authorized</h2>
</BlockSlot>
<BlockSlot @name="subheader">
<h3>Error 403</h3>
</BlockSlot>
<BlockSlot @name="body">
<p>
You must be granted permissions to view this data. Ask your administrator if you think you should have access.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/acl/index.html" rel="noopener noreferrer" target="_blank">Read the documentation</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/security-networking/production-acls" rel="noopener noreferrer" target="_blank">Follow the guide</a>
</li>
</BlockSlot>
</EmptyState>
{{/if}}
</BlockSlot>
</AppView>
<AppError @error={{error}} />
{{/if}}

View File

@ -1,15 +1,6 @@
import { validatePresence, validateLength } from 'ember-changeset-validations/validators';
import config from 'consul-ui/config/environment';
export default Object.assign(
{
SourceName: [validatePresence(true), validateLength({ min: 1 })],
DestinationName: [validatePresence(true), validateLength({ min: 1 })],
Action: validatePresence(true),
},
config.CONSUL_NAMESPACES_ENABLED
? {
SourceNS: [validatePresence(true), validateLength({ min: 1 })],
DestinationNS: [validatePresence(true), validateLength({ min: 1 })],
}
: {}
);
export default {
SourceName: [validatePresence(true), validateLength({ min: 1 })],
DestinationName: [validatePresence(true), validateLength({ min: 1 })],
Action: validatePresence(true),
};

View File

@ -51,6 +51,8 @@
},
"devDependencies": {
"@babel/core": "^7.2.2",
"@babel/helper-call-delegate": "^7.10.1",
"@babel/plugin-proposal-class-properties": "^7.10.1",
"@babel/plugin-proposal-object-rest-spread": "^7.5.5",
"@ember/optional-features": "^1.3.0",
"@glimmer/component": "^1.0.0",
@ -103,9 +105,12 @@
"ember-power-select-with-create": "^0.7.0",
"ember-qunit": "^4.6.0",
"ember-ref-modifier": "^1.0.0",
"ember-render-helpers": "^0.1.1",
"ember-resolver": "^7.0.0",
"ember-router-helpers": "^0.4.0",
"ember-sinon-qunit": "4.0.1",
"ember-source": "~3.16.0",
"ember-stargate": "^0.2.0",
"ember-test-selectors": "^4.0.0",
"ember-tooltips": "^3.4.3",
"ember-truth-helpers": "^2.0.0",

View File

@ -33,13 +33,14 @@ Feature: dc / intentions / create: Intention Create
# Specifically set deny
And I click "[value=deny]"
And I submit
Then a POST request was made to "/v1/connect/intentions?dc=datacenter" with the body from yaml
Then a POST request was made to "/v1/connect/intentions?dc=datacenter" from yaml
---
SourceName: web
DestinationName: db
Action: deny
body:
SourceName: web
DestinationName: db
Action: deny
---
Then the url should be /datacenter/intentions
And the title should be "Intentions - Consul"
And "[data-notification]" has the "notification-create" class
And "[data-notification]" has the "notification-update" class
And "[data-notification]" has the "success" class

View File

@ -0,0 +1,58 @@
@setupApplicationTest
Feature: dc / intentions / deleting: Deleting items with confirmations, success and error notifications
Background:
Given 1 datacenter model with the value "datacenter"
Scenario: Deleting a intention model from the intention listing page
Given 1 intention model from yaml
---
SourceName: name
ID: ee52203d-989f-4f7a-ab5a-2bef004164ca
---
When I visit the intentions page for yaml
---
dc: datacenter
---
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=datacenter"
And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class
Scenario: Deleting an intention from the intention detail page
When I visit the intention page for yaml
---
dc: datacenter
intention: ee52203d-989f-4f7a-ab5a-2bef004164ca
---
And I click delete
And I click confirmDelete
Then a DELETE request was made to "/v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter"
And "[data-notification]" has the "notification-delete" class
And "[data-notification]" has the "success" class
Scenario: Deleting an intention from the intention detail page and getting an error
When I visit the intention page for yaml
---
dc: datacenter
intention: ee52203d-989f-4f7a-ab5a-2bef004164ca
---
Given the url "/v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter" responds with a 500 status
And I click delete
And I click confirmDelete
And "[data-notification]" has the "notification-update" class
And "[data-notification]" has the "error" class
Scenario: Deleting an intention from the intention detail page and getting an error due to a duplicate intention
When I visit the intention page for yaml
---
dc: datacenter
intention: ee52203d-989f-4f7a-ab5a-2bef004164ca
---
Given the url "/v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter" responds with from yaml
---
status: 500
body: "duplicate intention found:"
---
And I click delete
And I click confirmDelete
And "[data-notification]" has the "notification-update" class
And "[data-notification]" has the "error" class
And I see the text "Intention exists" in "[data-notification] strong"

View File

@ -16,6 +16,11 @@ Feature: dc / intentions / filtered-select: Intention Service Select Dropdowns
- Name: service-3
Kind: connect-proxy
---
And 1 intention model from yaml
---
SourceName: 'service-0'
DestinationName: 'service-1'
---
When I visit the intention page for yaml
---
dc: datacenter
@ -42,6 +47,11 @@ Feature: dc / intentions / filtered-select: Intention Service Select Dropdowns
Namespace: nspace
Kind: ~
---
And 1 intention model from yaml
---
SourceName: 'service-0'
DestinationName: 'service-0'
---
When I visit the intention page for yaml
---
dc: datacenter

View File

@ -24,7 +24,6 @@ Feature: deleting: Deleting items with confirmations, success and error notifica
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Edit | Listing | Method | URL | Data |
| kv | kvs | DELETE | /v1/kv/key-name?dc=datacenter&ns=@!namespace | ["key-name"] |
| intention | intentions | DELETE | /v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter | {"SourceName": "name", "ID": "ee52203d-989f-4f7a-ab5a-2bef004164ca"} |
| token | tokens | DELETE | /v1/acl/token/001fda31-194e-4ff1-a5ec-589abf2cafd0?dc=datacenter&ns=@!namespace | {"AccessorID": "001fda31-194e-4ff1-a5ec-589abf2cafd0"} |
# | acl | acls | PUT | /v1/acl/destroy/something?dc=datacenter | {"Name": "something", "ID": "something"} |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
@ -53,10 +52,6 @@ Feature: deleting: Deleting items with confirmations, success and error notifica
-----------------------------------------------------------------------------------------------------------------------------------------------------------
| Model | Method | URL | Slug |
| kv | DELETE | /v1/kv/key-name?dc=datacenter&ns=@!namespace | kv: key-name |
| intention | DELETE | /v1/connect/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=datacenter | intention: ee52203d-989f-4f7a-ab5a-2bef004164ca |
| token | DELETE | /v1/acl/token/001fda31-194e-4ff1-a5ec-589abf2cafd0?dc=datacenter&ns=@!namespace | token: 001fda31-194e-4ff1-a5ec-589abf2cafd0 |
# | acl | PUT | /v1/acl/destroy/something?dc=datacenter | acl: something |
-----------------------------------------------------------------------------------------------------------------------------------------------------------
@ignore
Scenario: Sort out the wide tables ^
Then ok

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

@ -17,7 +17,6 @@ Feature: submit-blank
| Model | Slug |
| kv | kv |
| acl | acls |
| intention | intentions |
--------------------------
@ignore
Scenario: The button is disabled

View File

@ -8,6 +8,9 @@ export default function(type) {
env: function() {
return CONSUL_NSPACES_ENABLED;
},
var: function() {
return CONSUL_NSPACES_ENABLED;
},
})
);
const adapter = container.owner.lookup(`adapter:${type}`);

View File

@ -11,6 +11,9 @@ export default function(type) {
case 'proxy':
requests = ['/v1/catalog/connect'];
break;
case 'intention':
requests = ['/v1/connect/intentions'];
break;
case 'node':
requests = ['/v1/internal/ui/nodes', '/v1/internal/ui/node/'];
break;

View File

@ -13,14 +13,16 @@ module('Integration | Adapter | intention', function(hooks) {
});
assert.equal(actual, expected);
});
test('urlForQueryRecord returns the correct url', function(assert) {
test('requestForQueryRecord returns the correct url', function(assert) {
const adapter = this.owner.lookup('adapter:intention');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/connect/intentions/${id}?dc=${dc}`;
const actual = adapter.requestForQueryRecord(client.url, {
dc: dc,
id: id,
});
const actual = adapter
.requestForQueryRecord(client.url, {
dc: dc,
id: id,
})
.split('\n')[0];
assert.equal(actual, expected);
});
test("requestForQueryRecord throws if you don't specify an id", function(assert) {

View File

@ -1,12 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | dc/intentions/create', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.owner.lookup('controller:dc/intentions/create');
assert.ok(controller);
});
});

View File

@ -1,12 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Controller | dc/intentions/edit', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.owner.lookup('controller:dc/intentions/edit');
assert.ok(controller);
});
});

View File

@ -1,37 +0,0 @@
import { module } from 'qunit';
import { setupTest } from 'ember-qunit';
import test from 'ember-sinon-qunit/test-support/test';
import Route from '@ember/routing/route';
import Mixin from 'consul-ui/mixins/intention/with-actions';
module('Unit | Mixin | intention/with actions', function(hooks) {
setupTest(hooks);
hooks.beforeEach(function() {
this.subject = function() {
const MixedIn = Route.extend(Mixin);
this.owner.register('test-container:intention/with-actions-object', MixedIn);
return this.owner.lookup('test-container:intention/with-actions-object');
};
});
// Replace this with your real tests.
test('it works', function(assert) {
const subject = this.subject();
assert.ok(subject);
});
test('errorCreate returns a different status code if a duplicate intention is found', function(assert) {
const subject = this.subject();
const expected = 'exists';
const actual = subject.errorCreate('error', {
errors: [{ status: '500', detail: 'duplicate intention found:' }],
});
assert.equal(actual, expected);
});
test('errorCreate returns the same code if there is no error', function(assert) {
const subject = this.subject();
const expected = 'error';
const actual = subject.errorCreate('error', {});
assert.equal(actual, expected);
});
});

File diff suppressed because it is too large Load Diff