ui: Move routes to use data-sources (#8321)

* Add uri identifiers to all data source things and make them the same

1. Add uri identitifer to data-source service
2. Make <EventSource /> and <DataSource /> as close as possible
3. Add extra `.closed` method to get a list of inactive/closed/closing
data-sources from elsewhere

* Make the connections cleanup the least worst connection when required

* Pass the uri/request id through all the things

* Better user erroring

* Make event sources close on error

* Allow <DataLoader /> data slot to be configurable

* Allow the <DataWriter /> removed state to be configurable

* Don't error if meta is undefined

* Stitch together all the repositories into the data-source/sink

* Use data.source over repositories

* Add missing  <EventSource /> components

* Fix up the views/templates

* Disable all the old route based blocking query things

* We still need the repo for the mixin for the moment

* Don't default to default, default != ''
This commit is contained in:
John Cowen 2020-07-17 14:42:45 +01:00 committed by GitHub
parent 0b6a098aca
commit 8cb402eae7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 490 additions and 283 deletions

View File

@ -1,9 +1,10 @@
import Adapter from './application';
// TODO: Update to use this.formatDatacenter()
export default Adapter.extend({
requestForQuery: function(request, { dc, index }) {
requestForQuery: function(request, { dc, index, uri }) {
return request`
GET /v1/coordinate/nodes?${{ dc }}
X-Request-ID: ${uri}
${{ index }}
`;

View File

@ -2,12 +2,13 @@ import Adapter from './application';
// TODO: Update to use this.formatDatacenter()
export default Adapter.extend({
requestForQueryRecord: function(request, { dc, ns, index, id }) {
requestForQueryRecord: function(request, { dc, ns, index, id, uri }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
return request`
GET /v1/discovery-chain/${id}?${{ dc }}
X-Request-ID: ${uri}
${{
...this.formatNspace(ns),

View File

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

View File

@ -1,19 +1,21 @@
import Adapter from './application';
// TODO: Update to use this.formatDatacenter()
export default Adapter.extend({
requestForQuery: function(request, { dc, index, id }) {
requestForQuery: function(request, { dc, index, id, uri }) {
return request`
GET /v1/internal/ui/nodes?${{ dc }}
X-Request-ID: ${uri}
${{ index }}
`;
},
requestForQueryRecord: function(request, { dc, index, id }) {
requestForQueryRecord: function(request, { dc, index, id, uri }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
return request`
GET /v1/internal/ui/node/${id}?${{ dc }}
X-Request-ID: ${uri}
${{ index }}
`;

View File

@ -3,9 +3,10 @@ import { SLUG_KEY } from 'consul-ui/models/nspace';
// namespaces aren't categorized by datacenter, therefore no dc
export default Adapter.extend({
requestForQuery: function(request, { index }) {
requestForQuery: function(request, { index, uri }) {
return request`
GET /v1/namespaces
X-Request-ID: ${uri}
${{ index }}
`;

View File

@ -12,9 +12,10 @@ if (env('CONSUL_NSPACES_ENABLED')) {
}
export default Adapter.extend({
env: service('env'),
requestForQuery: function(request, { dc, ns, index }) {
requestForQuery: function(request, { dc, ns, index, uri }) {
return request`
GET /v1/internal/ui/oidc-auth-methods?${{ dc }}
X-Request-ID: ${uri}
${{
index,

View File

@ -1,12 +1,13 @@
import Adapter from './application';
// TODO: Update to use this.formatDatacenter()
export default Adapter.extend({
requestForQuery: function(request, { dc, ns, index, id }) {
requestForQuery: function(request, { dc, ns, index, id, uri }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
return request`
GET /v1/catalog/connect/${id}?${{ dc }}
X-Request-ID: ${uri}
${{
...this.formatNspace(ns),

View File

@ -1,10 +1,11 @@
import Adapter from './application';
// TODO: Update to use this.formatDatacenter()
export default Adapter.extend({
requestForQuery: function(request, { dc, ns, index, gateway }) {
requestForQuery: function(request, { dc, ns, index, gateway, uri }) {
if (typeof gateway !== 'undefined') {
return request`
GET /v1/internal/ui/gateway-services-nodes/${gateway}?${{ dc }}
X-Request-ID: ${uri}
${{
...this.formatNspace(ns),
@ -14,6 +15,7 @@ export default Adapter.extend({
} else {
return request`
GET /v1/internal/ui/services?${{ dc }}
X-Request-ID: ${uri}
${{
...this.formatNspace(ns),
@ -22,12 +24,13 @@ export default Adapter.extend({
`;
}
},
requestForQueryRecord: function(request, { dc, ns, index, id }) {
requestForQueryRecord: function(request, { dc, ns, index, id, uri }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
return request`
GET /v1/health/service/${id}?${{ dc }}
X-Request-ID: ${uri}
${{
...this.formatNspace(ns),

View File

@ -6,12 +6,13 @@ import { NSPACE_KEY } from 'consul-ui/models/nspace';
// TODO: Update to use this.formatDatacenter()
export default Adapter.extend({
requestForQuery: function(request, { dc, ns, index, id }) {
requestForQuery: function(request, { dc, ns, index, id, uri }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
return request`
GET /v1/session/node/${id}?${{ dc }}
X-Request-ID: ${uri}
${{
...this.formatNspace(ns),

View File

@ -9,23 +9,28 @@
{{#let (hash
data=data
error=error
dispatchError=(queue (action (mut error) value="error.errors.firstObject") (action dispatch "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}}
{{#yield-slot name="data"}}
{{yield api}}
{{else}}
{{! 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 (and src (or (not once) (state-matches state "loading")))}}
<DataSource
@open={{open}}
@src={{src}}
@onchange={{queue (action "change" value="data") (action dispatch "SUCCESS")}}
@onerror={{api.dispatchError}}
/>
{{/if}}
</State>
{{/if}}
{{/yield-slot}}
<State @matches="loading">
{{#yield-slot name="loading"}}

View File

@ -22,7 +22,7 @@ export default Component.extend(Slotted, {
},
actions: {
isLoaded: function() {
return typeof this.items !== 'undefined';
return typeof this.items !== 'undefined' || typeof this.src === 'undefined';
},
change: function(data) {
set(this, 'data', this.onchange(data));

View File

@ -91,7 +91,10 @@ export default Component.extend({
);
const error = err => {
try {
this.onerror(err);
const error = get(err, 'error.errors.firstObject');
if (get(error || {}, 'status') !== '429') {
this.onerror(err);
}
this.logger.execute(err);
} catch (err) {
this.logger.execute(err);
@ -107,9 +110,7 @@ export default Component.extend({
}
},
error: e => {
if (get(e, 'error.errors.firstObject.status') !== '429') {
error(e);
}
error(e);
},
});
replace(this, '_remove', remove);

View File

@ -32,16 +32,16 @@
</State>
<State @matches="removed">
<Notification @after={{queue (action dispatch "RESET") (action ondelete)}}>
{{#yield-slot name="removed"}}
{{yield api}}
{{else}}
{{#yield-slot name="removed" params=(block-params (component 'notification' after=(queue (action dispatch "RESET") (action ondelete))))}}
{{yield api}}
{{else}}
<Notification @after={{queue (action dispatch "RESET") (action ondelete)}}>
<p data-notification role="alert" class="success notification-delete">
<strong>Success!</strong>
Your {{type}} has been deleted.
</p>
{{/yield-slot}}
</Notification>
</Notification>
{{/yield-slot}}
</State>
<State @matches="persisted">

View File

@ -0,0 +1,3 @@
{{yield (hash
close=(action "close")
)}}

View File

@ -1,10 +1,24 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { get, set } from '@ember/object';
const replace = function(
obj,
prop,
value,
destroy = (prev = null, value) => (typeof prev === 'function' ? prev() : null)
) {
const prev = obj[prop];
if (prev !== value) {
destroy(prev, value);
}
return set(obj, prop, value);
};
export default Component.extend({
tagName: '',
dom: service('dom'),
logger: service('logger'),
data: service('data-source/service'),
closeOnDestroy: true,
onerror: function(e) {
this.logger.execute(e.error);
@ -14,25 +28,64 @@ export default Component.extend({
this._listeners = this.dom.listeners();
},
willDestroyElement: function() {
if (this.closeOnDestroy && typeof (this.src || {}).close === 'function') {
this.src.close();
this.src.willDestroy();
if (this.closeOnDestroy) {
this.actions.close.apply(this, []);
}
this._listeners.remove();
this._super(...arguments);
},
didReceiveAttrs: function() {
this._listeners.remove();
if (typeof (this.src || {}).addEventListener === 'function') {
this._listeners.add(this.src, {
error: e => {
try {
this.onerror(e);
} catch (err) {
this.logger.execute(e.error);
}
},
});
this._super(...arguments);
// only close and reopen if the uri changes
// otherwise this will fire whenever the proxies data changes
if (get(this, 'src.configuration.uri') !== get(this, 'source.configuration.uri')) {
this.actions.open.apply(this, []);
}
},
actions: {
open: function() {
replace(this, 'source', this.data.open(this.src, this), (prev, source) => {
// Makes sure any previous source (if different) is ALWAYS closed
if (typeof prev !== 'undefined') {
this.data.close(prev, this);
}
});
replace(this, 'proxy', this.src, (prev, proxy) => {
// Makes sure any previous proxy (if different) is ALWAYS closed
if (typeof prev !== 'undefined') {
prev.destroy();
}
});
const error = err => {
try {
const error = get(err, 'error.errors.firstObject');
if (get(error || {}, 'status') !== '429') {
this.onerror(err);
}
this.logger.execute(err);
} catch (err) {
this.logger.execute(err);
}
};
// set up the listeners (which auto cleanup on component destruction)
// we only need errors here as this only uses proxies which
// automatically update their data
const remove = this._listeners.add(this.source, {
error: e => {
error(e);
},
});
replace(this, '_remove', remove);
},
close: function() {
if (typeof this.source !== 'undefined') {
this.data.close(this.source, this);
replace(this, '_remove', undefined);
set(this, 'source', undefined);
}
if (typeof this.proxy !== 'undefined') {
this.proxy.destroy();
}
},
},
});

View File

@ -1,30 +0,0 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { get } from '@ember/object';
import { alias } from '@ember/object/computed';
export default Controller.extend({
dom: service('dom'),
notify: service('flashMessages'),
items: alias('item.Services'),
actions: {
error: function(e) {
if (e.target.readyState === 1) {
// OPEN
if (get(e, 'error.errors.firstObject.status') === '404') {
this.notify.add({
destroyOnClick: false,
sticky: true,
type: 'warning',
action: 'update',
});
[e.target, this.tomography, this.sessions].forEach(function(item) {
if (item && typeof item.close === 'function') {
item.close();
}
});
}
}
},
},
});

View File

@ -1,9 +1,7 @@
import Controller from '@ember/controller';
import { alias } from '@ember/object/computed';
import { get, computed } from '@ember/object';
export default Controller.extend({
items: alias('item.Services'),
queryParams: {
search: {
as: 'filter',

9
ui-v2/app/helpers/uri.js Normal file
View File

@ -0,0 +1,9 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
export default Helper.extend({
encoder: service('encoder'),
compute(params, hash) {
return this.encoder.uriJoin(params);
},
});

View File

@ -4,20 +4,7 @@ export function initialize(container) {
if (env('CONSUL_UI_DISABLE_REALTIME')) {
return;
}
['node', 'coordinate', 'session', 'service', 'proxy', 'discovery-chain', 'intention']
.concat(env('CONSUL_NSPACES_ENABLED') ? ['nspace/enabled'] : [])
.map(function(item) {
// create repositories that return a promise resolving to an EventSource
return {
service: `repository/${item}/event-source`,
extend: 'repository/type/event-source',
// Inject our original respository that is used by this class
// within the callable of the EventSource
services: {
content: `repository/${item}`,
},
};
})
[]
.concat(
['policy', 'role'].map(function(item) {
// create repositories that return a promise resolving to an EventSource
@ -33,50 +20,6 @@ export function initialize(container) {
})
)
.concat([
// These are the routes where we overwrite the 'default'
// repo service. Default repos are repos that return a promise resolving to
// an ember-data record or recordset
{
route: 'dc/nodes/index',
services: {
repo: 'repository/node/event-source',
},
},
{
route: 'dc/nodes/show',
services: {
repo: 'repository/node/event-source',
coordinateRepo: 'repository/coordinate/event-source',
sessionRepo: 'repository/session/event-source',
},
},
{
route: 'dc/services/index',
services: {
repo: 'repository/service/event-source',
},
},
{
route: 'dc/services/show',
services: {
repo: 'repository/service/event-source',
chainRepo: 'repository/discovery-chain/event-source',
intentionRepo: 'repository/intention/event-source',
},
},
{
route: 'dc/services/instance',
services: {
repo: 'repository/service/event-source',
proxyRepo: 'repository/proxy/event-source',
},
},
{
route: 'dc/intentions/index',
services: {
repo: 'repository/intention/event-source',
},
},
{
service: 'form',
services: {
@ -85,18 +28,6 @@ export function initialize(container) {
},
},
])
.concat(
env('CONSUL_NSPACES_ENABLED')
? [
{
route: 'dc/nspaces/index',
services: {
repo: 'repository/nspace/enabled/event-source',
},
},
]
: []
)
.forEach(function(definition) {
if (typeof definition.extend !== 'undefined') {
// Create the class instances that we need
@ -111,9 +42,6 @@ export function initialize(container) {
// but hardcode this for the moment
if (typeof definition.route !== 'undefined') {
container.inject(`route:${definition.route}`, name, `service:${servicePath}`);
if (env('CONSUL_NSPACES_ENABLED') && definition.route.startsWith('dc/')) {
container.inject(`route:nspace/${definition.route}`, name, `service:${servicePath}`);
}
} else {
container.inject(`service:${definition.service}`, name, `service:${servicePath}`);
}

View File

@ -4,6 +4,7 @@ import { hash } from 'rsvp';
export default Route.extend({
repo: service('repository/node'),
data: service('data-source/service'),
queryParams: {
search: {
as: 'filter',
@ -12,8 +13,9 @@ export default Route.extend({
},
model: function(params) {
const dc = this.modelFor('dc').dc.Name;
const nspace = '*';
return hash({
items: this.repo.findAllByDatacenter(dc, this.modelFor('nspace').nspace.substr(1)),
items: this.data.source(uri => uri`/${nspace}/${dc}/nodes`),
leader: this.repo.findByLeader(dc),
});
},

View File

@ -3,20 +3,20 @@ import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
export default Route.extend({
repo: service('repository/node'),
sessionRepo: service('repository/session'),
coordinateRepo: service('repository/coordinate'),
data: service('data-source/service'),
model: function(params) {
const dc = this.modelFor('dc').dc.Name;
const nspace = this.modelFor('nspace').nspace.substr(1);
const name = params.name;
return hash({
item: this.repo.findBySlug(name, dc, nspace),
dc: dc,
nspace: nspace,
item: this.data.source(uri => uri`/${nspace}/${dc}/node/${name}`),
}).then(model => {
return hash({
...model,
sessions: this.sessionRepo.findByNode(name, dc, nspace),
tomography: this.coordinateRepo.findAllByNode(name, dc),
tomography: this.data.source(uri => uri`/${nspace}/${dc}/coordinates/for-node/${name}`),
sessions: this.data.source(uri => uri`/${nspace}/${dc}/sessions/for-node/${name}`),
});
});
},

View File

@ -4,6 +4,7 @@ import { hash } from 'rsvp';
import WithNspaceActions from 'consul-ui/mixins/nspace/with-actions';
export default Route.extend(WithNspaceActions, {
data: service('data-source/service'),
repo: service('repository/nspace'),
queryParams: {
search: {
@ -13,7 +14,7 @@ export default Route.extend(WithNspaceActions, {
},
model: function(params) {
return hash({
items: this.repo.findAll(),
items: this.data.source(uri => uri`/*/*/namespaces`),
isLoading: false,
});
},

View File

@ -3,7 +3,7 @@ import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
export default Route.extend({
repo: service('repository/service'),
data: service('data-source/service'),
queryParams: {
search: {
as: 'filter',
@ -29,12 +29,13 @@ export default Route.extend({
.trim();
}
}
const nspace = this.modelFor('nspace').nspace.substr(1);
const dc = this.modelFor('dc').dc.Name;
return hash({
nspace: nspace,
dc: dc,
terms: terms !== '' ? terms.split('\n') : [],
items: this.repo.findAllByDatacenter(
this.modelFor('dc').dc.Name,
this.modelFor('nspace').nspace.substr(1)
),
items: this.data.source(uri => uri`/${nspace}/${dc}/services`),
});
},
setupController: function(controller, model) {

View File

@ -4,38 +4,46 @@ import { hash } from 'rsvp';
import { get } from '@ember/object';
export default Route.extend({
repo: service('repository/service'),
proxyRepo: service('repository/proxy'),
data: service('data-source/service'),
model: function(params) {
const dc = this.modelFor('dc').dc.Name;
const nspace = this.modelFor('nspace').nspace.substr(1);
const nspace = this.modelFor('nspace').nspace.substr(1) || 'default';
return hash({
dc: dc,
nspace: nspace || 'default',
item: this.repo.findInstanceBySlug(params.id, params.node, params.name, dc, nspace),
nspace: nspace,
item: this.data.source(
uri => uri`/${nspace}/${dc}/service-instance/${params.id}/${params.node}/${params.name}`
),
}).then(model => {
// this will not be run in a blocking loop, but this is ok as
// its highly unlikely that a service will suddenly change to being a
// connect-proxy or vice versa so leave as is for now
return hash({
...model,
proxyMeta:
// proxies and mesh-gateways can't have proxies themselves so don't even look
['connect-proxy', 'mesh-gateway'].includes(get(model.item, 'Kind'))
? null
: this.proxyRepo.findInstanceBySlug(params.id, params.node, params.name, dc, nspace),
...model,
: this.data.source(
uri =>
uri`/${nspace}/${dc}/proxy-instance/${params.id}/${params.node}/${params.name}`
),
}).then(model => {
if (typeof get(model, 'proxyMeta.ServiceID') === 'undefined') {
return model;
}
const proxyName = get(model, 'proxyMeta.ServiceName');
const proxyID = get(model, 'proxyMeta.ServiceID');
const proxyNode = get(model, 'proxyMeta.Node');
const proxy = {
id: get(model, 'proxyMeta.ServiceID'),
node: get(model, 'proxyMeta.Node'),
name: get(model, 'proxyMeta.ServiceName'),
};
return hash({
...model,
// Proxies have identical dc/nspace as their parent instance
// No need to use Proxy's dc/nspace response
proxy: this.repo.findInstanceBySlug(proxyID, proxyNode, proxyName, dc, nspace),
...model,
proxy: this.data.source(
uri => uri`/${nspace}/${dc}/service-instance/${proxy.id}/${proxy.node}/${proxy.name}`
),
});
});
});

View File

@ -4,9 +4,7 @@ import { hash } from 'rsvp';
import { get } from '@ember/object';
export default Route.extend({
repo: service('repository/service'),
chainRepo: service('repository/discovery-chain'),
proxyRepo: service('repository/proxy'),
data: service('data-source/service'),
settings: service('settings'),
model: function(params, transition = {}) {
const dc = this.modelFor('dc').dc.Name;
@ -14,8 +12,8 @@ export default Route.extend({
return hash({
slug: params.name,
dc: dc,
nspace: nspace || 'default',
item: this.repo.findBySlug(params.name, dc, nspace),
nspace: nspace,
item: this.data.source(uri => uri`/${nspace}/${dc}/service/${params.name}`),
urls: this.settings.findBySlug('urls'),
proxies: [],
})
@ -25,16 +23,20 @@ export default Route.extend({
)
? model
: hash({
chain: this.chainRepo.findBySlug(params.name, dc, nspace),
proxies: this.proxyRepo.findAllBySlug(params.name, dc, nspace),
...model,
chain: this.data.source(uri => uri`/${nspace}/${dc}/discovery-chain/${params.name}`),
proxies: this.data.source(
uri => uri`/${nspace}/${dc}/proxies/for-service/${params.name}`
),
});
})
.then(model => {
return ['ingress-gateway', 'terminating-gateway'].includes(get(model, 'item.Service.Kind'))
? hash({
gatewayServices: this.repo.findGatewayBySlug(params.name, dc, nspace),
...model,
gatewayServices: this.data.source(
uri => uri`/${nspace}/${dc}/gateways/for-service/${params.name}`
),
})
: model;
});

View File

@ -3,6 +3,8 @@ import Service, { inject as service } from '@ember/service';
export default Service.extend({
dom: service('dom'),
env: service('env'),
data: service('data-source/service'),
sources: service('repository/type/event-source'),
init: function() {
this._super(...arguments);
this._listeners = this.dom.listeners();
@ -42,21 +44,41 @@ export default Service.extend({
}
return Promise.resolve(e);
},
purge: function() {
purge: function(statusCode = 0) {
[...this.connections].forEach(function(connection) {
// Cancelled
connection.abort(0);
connection.abort(statusCode);
});
this.connections = new Set();
},
acquire: function(request) {
this.connections.add(request);
if (this.connections.size > this.env.var('CONSUL_HTTP_MAX_CONNECTIONS')) {
const connection = this.connections.values().next().value;
this.connections.delete(connection);
// Too Many Requests
connection.abort(429);
if (this.connections.size >= this.env.var('CONSUL_HTTP_MAX_CONNECTIONS')) {
const closed = this.data.closed();
let connection = [...this.connections].find(item => {
const id = item.headers()['x-request-id'];
if (id) {
return closed.includes(item.headers()['x-request-id']);
}
return false;
});
if (typeof connection === 'undefined') {
// all connections are being used on the page
// if the new one is a blocking query then cancel the oldest connection
if (request.headers()['content-type'] === 'text/event-stream') {
connection = this.connections.values().next().value;
}
// otherwise wait for a connection to become available
}
// cancel the connection
if (typeof connection !== 'undefined') {
// if its a shared blocking query cancel everything
// listening to it
this.release(connection);
// Too Many Requests
connection.abort(429);
}
}
this.connections.add(request);
},
release: function(request) {
this.connections.delete(request);

View File

@ -68,7 +68,7 @@ const parseBody = function(strs, ...values) {
return [body, ...values];
};
const CLIENT_HEADERS = [CACHE_CONTROL];
const CLIENT_HEADERS = [CACHE_CONTROL, 'X-Request-ID'];
export default Service.extend({
dom: service('dom'),
connections: service('client/connections'),

View File

@ -12,7 +12,10 @@ export default Service.extend({
return xhr(options);
},
request: function(params) {
const request = new Request(params.method, params.url, { body: params.data || {} });
const request = new Request(params.method, params.url, {
['x-request-id']: params.clientHeaders['x-request-id'],
body: params.data || {},
});
const options = {
...params,
beforeSend: function(xhr) {
@ -51,6 +54,7 @@ export default Service.extend({
};
request.fetch = () => {
this.xhr(options);
return request;
};
return request;
},

View File

@ -4,6 +4,7 @@ import { setProperties } from '@ember/object';
export default Service.extend({
settings: service('settings'),
intention: service('repository/intention'),
session: service('repository/session'),
prepare: function(sink, data, instance) {
return setProperties(instance, data);
},

View File

@ -3,7 +3,17 @@ import { get } from '@ember/object';
export default Service.extend({
datacenters: service('repository/dc'),
nodes: service('repository/node'),
node: service('repository/node'),
gateways: service('repository/service'),
services: service('repository/service'),
service: service('repository/service'),
['service-instance']: service('repository/service'),
proxies: service('repository/proxy'),
['proxy-instance']: service('repository/proxy'),
['discovery-chain']: service('repository/discovery-chain'),
coordinates: service('repository/coordinate'),
sessions: service('repository/session'),
namespaces: service('repository/nspace'),
intentions: service('repository/intention'),
intention: service('repository/intention'),
@ -12,17 +22,15 @@ export default Service.extend({
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) {
// TODO: Consider adding/requiring nspace, dc, model, action, ...rest
const [, nspace, dc, model, ...rest] = src.split('/');
// TODO: Consider throwing if we have an empty nspace or dc
// we are going to use '*' for 'all' when we need that
// and an empty value is the same as 'default'
// reasoning for potentially doing it here is, uri's should
// always be complete, they should never have things like '///model'
// TODO: Consider adding/requiring 'action': nspace, dc, model, action, ...rest
const [, nspace, dc, model, ...rest] = src.split('/').map(decodeURIComponent);
// nspaces can be filled, blank or *
// so we might get urls like //dc/services
let find;
const repo = this[model];
if (repo.shouldReconcile(src)) {
@ -41,32 +49,72 @@ export default Service.extend({
case 'namespaces':
find = configuration => repo.findAll(configuration);
break;
case 'token':
find = configuration => repo.self(rest[1], dc);
break;
case 'services':
case 'nodes':
case 'roles':
case 'policies':
find = configuration => repo.findAllByDatacenter(dc, nspace, configuration);
break;
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);
find = configuration => repo.findByService(slug, dc, nspace, configuration);
break;
default:
find = configuration => repo.findAllByDatacenter(dc, nspace, configuration);
break;
}
break;
case 'coordinates':
[method, ...slug] = rest;
switch (method) {
case 'for-node':
find = configuration => repo.findAllByNode(slug, dc, configuration);
break;
}
break;
case 'proxies':
[method, ...slug] = rest;
switch (method) {
case 'for-service':
find = configuration => repo.findAllBySlug(slug, dc, nspace, configuration);
break;
}
break;
case 'gateways':
[method, ...slug] = rest;
switch (method) {
case 'for-service':
find = configuration => repo.findGatewayBySlug(slug, dc, nspace, configuration);
break;
}
break;
case 'sessions':
[method, ...slug] = rest;
switch (method) {
case 'for-node':
find = configuration => repo.findByNode(slug, dc, nspace, configuration);
break;
}
break;
case 'token':
find = configuration => repo.self(rest[1], dc);
break;
case 'service':
case 'discovery-chain':
case 'node':
find = configuration => repo.findBySlug(rest[0], dc, nspace, configuration);
break;
case 'service-instance':
case 'proxy-instance':
// id, node, service
find = configuration =>
repo.findInstanceBySlug(rest[0], rest[1], rest[2], dc, nspace, configuration);
break;
case 'policy':
case 'intention':
// TODO: Are we going to need to encode/decode here...?
slug = rest.join('/');
slug = rest[0];
if (slug) {
find = configuration => repo.findBySlug(slug, dc, nspace, configuration);
} else {

View File

@ -18,7 +18,7 @@ export default Service.extend({
return find(configuration)
.then(maybeCall(close, ifNotBlocking(this.settings)))
.then(function(res) {
if (typeof get(res, 'meta.cursor') === 'undefined') {
if (typeof get(res || {}, 'meta.cursor') === 'undefined') {
close();
}
return res;

View File

@ -11,6 +11,7 @@ export default Service.extend({
},
{
key: src,
uri: configuration.uri,
}
);
},

View File

@ -1,4 +1,5 @@
import Service, { inject as service } from '@ember/service';
import { proxy } from 'consul-ui/utils/dom/event-source';
import MultiMap from 'mnemonist/multi-map';
@ -10,9 +11,9 @@ let cache = null;
let sources = null;
// keeps a count of currently in use EventSources
let usage = null;
export default Service.extend({
dom: service('dom'),
encoder: service('encoder'),
consul: service('data-source/protocols/http'),
settings: service('data-source/protocols/local-storage'),
@ -33,29 +34,68 @@ export default Service.extend({
});
cache = null;
sources = null;
usage.clear();
usage = null;
},
source: function(cb, attrs) {
const src = cb(this.encoder.uriTag());
return new Promise((resolve, reject) => {
const ref = {};
const source = this.open(src, ref, true);
source.configuration.ref = ref;
const remove = this._listeners.add(source, {
message: e => {
remove();
// the source only gets wrapped in the proxy
// after the first message
// but the proxy itself is resolve to the route
resolve(proxy(e.target, e.data));
},
error: e => {
remove();
this.close(source, ref);
reject(e.error);
},
});
if (typeof source.getCurrentEvent() !== 'undefined') {
source.dispatchEvent(source.getCurrentEvent());
}
});
},
unwrap: function(src, ref) {
const source = src._source;
usage.set(source, ref);
usage.remove(source, source.configuration.ref);
delete source.configuration.ref;
return source;
},
open: function(uri, ref, open = false) {
if (typeof uri !== 'string') {
return this.unwrap(uri, ref);
}
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.
if (uri.indexOf('://') === -1) {
uri = `consul://${uri}`;
}
let [providerName, pathname] = uri.split('://');
const provider = this[providerName];
if (!sources.has(uri)) {
let [providerName, pathname] = uri.split('://');
const provider = this[providerName];
let configuration = {};
if (cache.has(uri)) {
configuration = cache.get(uri);
}
configuration.uri = uri;
source = provider.source(pathname, configuration);
this._listeners.add(source, {
const remove = this._listeners.add(source, {
close: e => {
// a close could be fired either by:
// 1. A non-blocking query leaving the page
// 2. A non-blocking query responding
// 3. A blocking query responding when is in a closing state
// 3. A non-blocking query or a blocking query being cancelled
const source = e.target;
source.removeEventListener('close', close);
const event = source.getCurrentEvent();
const cursor = source.configuration.cursor;
// only cache data if we have any
@ -67,20 +107,25 @@ export default Service.extend({
}
// the data is cached delete the EventSource
if (!usage.has(source)) {
// A non-blocking query could close but still be on the page
sources.delete(uri);
}
remove();
},
});
sources.set(uri, source);
} else {
source = sources.get(uri);
// bump to the end of the list
sources.delete(uri);
sources.set(uri, source);
}
// 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) {
if (!usage.has(source) || source.readyState > 1 || open) {
source.open();
}
// set/increase the usage counter
@ -88,6 +133,8 @@ export default Service.extend({
return source;
},
close: function(source, ref) {
// this close is called when the source has either left the page
// or in the case of a proxied source, it errors
if (source) {
// decrease the usage counter
usage.remove(source, ref);
@ -95,7 +142,22 @@ export default Service.extend({
// close it (data caching is dealt with by the above 'close' event listener)
if (!usage.has(source)) {
source.close();
if (source.readyState === 2) {
// in the case that a non-blocking query is on the page
// and it has already responded and has therefore been cached
// but not removed itself from sources
// delete from sources
sources.delete(source.configuration.uri);
}
}
}
},
closed: function() {
// anything that is closed or closing
return [...sources.entries()]
.filter(([key, item]) => {
return item.readyState > 1;
})
.map(item => item[0]);
},
});

View File

@ -0,0 +1,27 @@
import Service from '@ember/service';
import atob from 'consul-ui/utils/atob';
import btoa from 'consul-ui/utils/btoa';
export default Service.extend({
uriComponent: encodeURIComponent,
atob: function() {
return atob(...arguments);
},
btoa: function() {
return btoa(...arguments);
},
uriJoin: function() {
return this.joiner(this.uriComponent, '/', '')(...arguments);
},
uriTag: function() {
return this.tag(this.uriJoin.bind(this));
},
joiner: (encoder, joiner = '', defaultValue = '') => (values, strs) =>
(strs || Array(values.length).fill(joiner)).reduce(
(prev, item, i) => `${prev}${item}${encoder(values[i] || defaultValue)}`,
''
),
tag: function(join) {
return (strs, ...values) => join(values, strs);
},
});

View File

@ -49,6 +49,7 @@ export default Service.extend({
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
query.uri = configuration.uri;
}
return this.store.query(this.getModelName(), query);
},
@ -60,6 +61,7 @@ export default Service.extend({
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
query.uri = configuration.uri;
}
return this.store.queryRecord(this.getModelName(), query);
},

View File

@ -18,6 +18,7 @@ export default RepositoryService.extend({
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
query.uri = configuration.uri;
}
return this.store.query(this.getModelName(), query);
},
@ -25,7 +26,10 @@ export default RepositoryService.extend({
return this.findAllByDatacenter(dc, configuration).then(function(coordinates) {
let results = {};
if (get(coordinates, 'length') > 1) {
results = tomography(node, coordinates.map(item => get(item, 'data')));
results = tomography(
node,
coordinates.map(item => get(item, 'data'))
);
}
results.meta = get(coordinates, 'meta');
return results;

View File

@ -29,6 +29,7 @@ export default RepositoryService.extend({
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
query.uri = configuration.uri;
}
return this.store.query(this.getModelName(), {
...query,

View File

@ -16,6 +16,7 @@ export default RepositoryService.extend({
const query = {};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
query.uri = configuration.uri;
}
return this.store.query(this.getModelName(), query);
},

View File

@ -21,6 +21,7 @@ export default RepositoryService.extend({
const query = {};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
query.uri = configuration.uri;
}
return this.store.query(this.getModelName(), query);
},

View File

@ -17,6 +17,7 @@ export default RepositoryService.extend({
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
query.uri = configuration.uri;
}
return this.store.query(this.getModelName(), query);
},

View File

@ -84,6 +84,7 @@ export default RepositoryService.extend({
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
query.uri = configuration.uri;
}
return this.store.query(this.getModelName(), query);
},

View File

@ -15,6 +15,7 @@ export default RepositoryService.extend({
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
query.uri = configuration.uri;
}
return this.store.query(this.getModelName(), query);
},

View File

@ -1,39 +1,70 @@
{{title item.Node}}
<EventSource @src={{item}} @onerror={{action "error"}} />
<EventSource @src={{sessions}} />
<EventSource @src={{tomography}} />
<AppView @class="node show">
<BlockSlot @name="notification" as |status type|>
{{!TODO: Move sessions to its own folder within nodes }}
{{partial 'dc/nodes/notifications'}}
</BlockSlot>
<BlockSlot @name="breadcrumbs">
<ol>
<li><a data-test-back href={{href-to 'dc.nodes'}}>All Nodes</a></li>
</ol>
</BlockSlot>
<BlockSlot @name="header">
<h1>
{{ item.Node }}
</h1>
<label for="toolbar-toggle"></label>
</BlockSlot>
<BlockSlot @name="nav">
<TabNav @items={{
compact
(array
(hash label="Health Checks" href=(href-to "dc.nodes.show.healthchecks") selected=(is-href "dc.nodes.show.healthchecks"))
(hash label="Service Instances" href=(href-to "dc.nodes.show.services") selected=(is-href "dc.nodes.show.services"))
(if tomography.distances (hash label="Round Trip Time" href=(href-to "dc.nodes.show.rtt") selected=(is-href "dc.nodes.show.rtt")) '')
(hash label="Lock Sessions" href=(href-to "dc.nodes.show.sessions") selected=(is-href "dc.nodes.show.sessions"))
(hash label="Metadata" href=(href-to "dc.nodes.show.metadata") selected=(is-href "dc.nodes.show.metadata"))
)
}}/>
</BlockSlot>
<BlockSlot @name="actions">
<CopyButton @value={{item.Address}} @name="Address">{{item.Address}}</CopyButton>
</BlockSlot>
<BlockSlot @name="content">
{{outlet}}
</BlockSlot>
</AppView>
<DataLoader as |api|>
<BlockSlot @name="data">
<EventSource @src={{sessions}} />
<EventSource @src={{tomography}} />
<EventSource @src={{item}} @onerror={{queue (action api.dispatchError)}} />
</BlockSlot>
<BlockSlot @name="error">
<AppError @error={{api.error}} />
</BlockSlot>
<BlockSlot @name="disconnected" as |Notification|>
{{#if (eq api.error.status "404")}}
<Notification @sticky={{true}}>
<p data-notification role="alert" class="warning notification-update">
<strong>Warning!</strong>
This node no longer exists in the catalog.
</p>
</Notification>
{{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>
{{/if}}
</BlockSlot>
<BlockSlot @name="loaded">
<AppView @class="node show">
<BlockSlot @name="notification" as |status type|>
{{!TODO: Move sessions to its own folder within nodes }}
{{partial 'dc/nodes/notifications'}}
</BlockSlot>
<BlockSlot @name="breadcrumbs">
<ol>
<li><a data-test-back href={{href-to 'dc.nodes'}}>All Nodes</a></li>
</ol>
</BlockSlot>
<BlockSlot @name="header">
<h1>
{{ item.Node }}
</h1>
<label for="toolbar-toggle"></label>
</BlockSlot>
<BlockSlot @name="nav">
<TabNav @items={{
compact
(array
(hash label="Health Checks" href=(href-to "dc.nodes.show.healthchecks") selected=(is-href "dc.nodes.show.healthchecks"))
(hash label="Service Instances" href=(href-to "dc.nodes.show.services") selected=(is-href "dc.nodes.show.services"))
(if tomography.distances (hash label="Round Trip Time" href=(href-to "dc.nodes.show.rtt") selected=(is-href "dc.nodes.show.rtt")) '')
(hash label="Lock Sessions" href=(href-to "dc.nodes.show.sessions") selected=(is-href "dc.nodes.show.sessions"))
(hash label="Metadata" href=(href-to "dc.nodes.show.metadata") selected=(is-href "dc.nodes.show.metadata"))
)
}}/>
</BlockSlot>
<BlockSlot @name="actions">
<CopyButton @value={{item.Address}} @name="Address">{{item.Address}}</CopyButton>
</BlockSlot>
<BlockSlot @name="content">
{{outlet}}
</BlockSlot>
</AppView>
</BlockSlot>
</DataLoader>

View File

@ -1,3 +1,4 @@
{{#let item.Services as |items|}}
<div id="services" class="tab-section">
<div role="tabpanel">
{{#if (gt items.length 0) }}
@ -23,4 +24,5 @@
</BlockSlot>
</ChangeableSet>
</div>
</div>
</div>
{{/let}}

View File

@ -39,14 +39,14 @@
<td>
<ConfirmationDialog @message="Are you sure you want to invalidate this session?">
<BlockSlot @name="action" as |confirm|>
<button data-test-delete type="button" class="type-delete" {{action confirm 'invalidateSession' item}}>Invalidate</button>
<button data-test-delete type="button" class="type-delete" onclick={{queue (action confirm 'invalidateSession' item) (refresh-route)}}>Invalidate</button>
</BlockSlot>
<BlockSlot @name="dialog" as |execute cancel message|>
<p>
{{message}}
</p>
<button type="button" class="type-delete" {{action execute}}>Confirm Invalidate</button>
<button type="button" class="type-cancel" {{ action cancel}}>Cancel</button>
<button type="button" class="type-cancel" {{action cancel}}>Cancel</button>
</BlockSlot>
</ConfirmationDialog>
</td>

View File

@ -1,6 +1,7 @@
{{title item.ID}}
<EventSource @src={{item}} @onerror={{action "error"}} />
<EventSource @src={{proxy}} />
<EventSource @src={{proxyMeta}} />
<AppView @class="instance show">
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/services/notifications'}}

View File

@ -2,6 +2,8 @@
<EventSource @src={{item}} @onerror={{action "error"}} />
<EventSource @src={{chain}} />
<EventSource @src={{intentions}} />
<EventSource @src={{proxies}} />
<EventSource @src={{gatewayServices}} />
<AppView @class="service show">
<BlockSlot @name="notification" as |status type|>
{{partial 'dc/services/notifications'}}

View File

@ -57,6 +57,7 @@ export default function(
// close after the dispatch so we can tell if it was an error whilst closed or not
// but make sure its before the promise tick
this.readyState = 2; // CLOSE
this.dispatchEvent({ type: 'close' });
})
.then(() => {
// This only gets called when the promise chain completely finishes

View File

@ -7,10 +7,10 @@ module('Integration | Adapter | coordinate', function(hooks) {
const adapter = this.owner.lookup('adapter:coordinate');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/coordinate/nodes?dc=${dc}`;
const actual = adapter.requestForQuery(client.url, {
const actual = adapter.requestForQuery(client.requestParams.bind(client), {
dc: dc,
});
assert.equal(actual, expected);
assert.equal(`${actual.method} ${actual.url}`, expected);
});
test('requestForQuery returns the correct body', function(assert) {
const adapter = this.owner.lookup('adapter:coordinate');

View File

@ -11,11 +11,11 @@ module('Integration | Adapter | discovery-chain', function(hooks) {
const adapter = this.owner.lookup('adapter:discovery-chain');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/discovery-chain/${id}?dc=${dc}`;
const actual = adapter.requestForQueryRecord(client.url, {
const actual = adapter.requestForQueryRecord(client.requestParams.bind(client), {
dc: dc,
id: id,
});
assert.equal(actual, expected);
assert.equal(`${actual.method} ${actual.url}`, expected);
});
test("requestForQueryRecord throws if you don't specify an id", function(assert) {
const adapter = this.owner.lookup('adapter:discovery-chain');

View File

@ -8,10 +8,10 @@ module('Integration | Adapter | intention', function(hooks) {
const adapter = this.owner.lookup('adapter:intention');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/connect/intentions?dc=${dc}`;
const actual = adapter.requestForQuery(client.url, {
const actual = adapter.requestForQuery(client.requestParams.bind(client), {
dc: dc,
});
assert.equal(actual, expected);
assert.equal(`${actual.method} ${actual.url}`, expected);
});
test('requestForQueryRecord returns the correct url', function(assert) {
const adapter = this.owner.lookup('adapter:intention');

View File

@ -8,20 +8,20 @@ module('Integration | Adapter | node', function(hooks) {
const adapter = this.owner.lookup('adapter:node');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/internal/ui/nodes?dc=${dc}`;
const actual = adapter.requestForQuery(client.url, {
const actual = adapter.requestForQuery(client.requestParams.bind(client), {
dc: dc,
});
assert.equal(actual, expected);
assert.equal(`${actual.method} ${actual.url}`, expected);
});
test('requestForQueryRecord returns the correct url', function(assert) {
const adapter = this.owner.lookup('adapter:node');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/internal/ui/node/${id}?dc=${dc}`;
const actual = adapter.requestForQueryRecord(client.url, {
const actual = adapter.requestForQueryRecord(client.requestParams.bind(client), {
dc: dc,
id: id,
});
assert.equal(actual, expected);
assert.equal(`${actual.method} ${actual.url}`, expected);
});
test("requestForQueryRecord throws if you don't specify an id", function(assert) {
const adapter = this.owner.lookup('adapter:node');

View File

@ -9,8 +9,8 @@ module('Integration | Adapter | nspace', function(hooks) {
const adapter = this.owner.lookup('adapter:nspace');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/namespaces`;
const actual = adapter.requestForQuery(client.url, {});
assert.equal(actual, expected);
const actual = adapter.requestForQuery(client.requestParams.bind(client), {});
assert.equal(`${actual.method} ${actual.url}`, expected);
});
test('requestForQueryRecord returns the correct url/method', function(assert) {
const adapter = this.owner.lookup('adapter:nspace');

View File

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