ui: Adds blocking query support to the service detail page (#5479)
This commit includes several pieces of functionality to enable services to be removed and the page to present information that this has happened but also keep the deleted information on the page. Along with the more usual blocking query based listing. To enable this: 1. Implements `meta` on the model (only available on collections in ember) 2. Adds new `catchable` ComputedProperty alongside a `listen` helper for working with specific errors that can be thrown from EventSources in an ember-like way. Briefly, normal computed properties update when a property changes, EventSources can additionally throw errors so we can catch them and show different visuals based on that.
This commit is contained in:
parent
45a2fa5a01
commit
1625a09372
11
ui-v2/app/computed/catchable.js
Normal file
11
ui-v2/app/computed/catchable.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import ComputedProperty from '@ember/object/computed';
|
||||||
|
import computedFactory from 'consul-ui/utils/computed/factory';
|
||||||
|
|
||||||
|
export default class Catchable extends ComputedProperty {
|
||||||
|
catch(cb) {
|
||||||
|
return this.meta({
|
||||||
|
catch: cb,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const computed = computedFactory(Catchable);
|
|
@ -1,9 +1,13 @@
|
||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
import { get, set, computed } from '@ember/object';
|
import { get, set, computed } from '@ember/object';
|
||||||
|
import { alias } from '@ember/object/computed';
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
import WithSearching from 'consul-ui/mixins/with-searching';
|
import WithSearching from 'consul-ui/mixins/with-searching';
|
||||||
export default Controller.extend(WithSearching, {
|
import WithEventSource, { listen } from 'consul-ui/mixins/with-event-source';
|
||||||
|
export default Controller.extend(WithEventSource, WithSearching, {
|
||||||
dom: service('dom'),
|
dom: service('dom'),
|
||||||
|
notify: service('flashMessages'),
|
||||||
|
items: alias('item.Nodes'),
|
||||||
init: function() {
|
init: function() {
|
||||||
this.searchParams = {
|
this.searchParams = {
|
||||||
serviceInstance: 's',
|
serviceInstance: 's',
|
||||||
|
@ -17,6 +21,19 @@ export default Controller.extend(WithSearching, {
|
||||||
// need this variable
|
// need this variable
|
||||||
set(this, 'selectedTab', 'instances');
|
set(this, 'selectedTab', 'instances');
|
||||||
},
|
},
|
||||||
|
item: listen('item').catch(function(e) {
|
||||||
|
if (e.target.readyState === 1) {
|
||||||
|
// OPEN
|
||||||
|
if (get(e, 'error.errors.firstObject.status') === '404') {
|
||||||
|
get(this, 'notify').add({
|
||||||
|
destroyOnClick: false,
|
||||||
|
sticky: true,
|
||||||
|
type: 'warning',
|
||||||
|
action: 'update',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
searchable: computed('items', function() {
|
searchable: computed('items', function() {
|
||||||
return get(this, 'searchables.serviceInstance')
|
return get(this, 'searchables.serviceInstance')
|
||||||
.add(get(this, 'items'))
|
.add(get(this, 'items'))
|
||||||
|
|
|
@ -34,6 +34,12 @@ export function initialize(container) {
|
||||||
repo: 'repository/service/event-source',
|
repo: 'repository/service/event-source',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
route: 'dc/services/show',
|
||||||
|
services: {
|
||||||
|
repo: 'repository/service/event-source',
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
.forEach(function(definition) {
|
.forEach(function(definition) {
|
||||||
if (typeof definition.extend !== 'undefined') {
|
if (typeof definition.extend !== 'undefined') {
|
||||||
|
|
|
@ -1,12 +1,38 @@
|
||||||
import Mixin from '@ember/object/mixin';
|
import Mixin from '@ember/object/mixin';
|
||||||
|
import { computed as catchable } from 'consul-ui/computed/catchable';
|
||||||
|
import purify from 'consul-ui/utils/computed/purify';
|
||||||
|
|
||||||
export default Mixin.create({
|
import WithListeners from 'consul-ui/mixins/with-listeners';
|
||||||
|
const PREFIX = '_';
|
||||||
|
export default Mixin.create(WithListeners, {
|
||||||
|
setProperties: function(model) {
|
||||||
|
const _model = {};
|
||||||
|
Object.keys(model).forEach(key => {
|
||||||
|
// here (see comment below on deleting)
|
||||||
|
if (typeof this[key] !== 'undefined' && this[key].isDescriptor) {
|
||||||
|
_model[`${PREFIX}${key}`] = model[key];
|
||||||
|
const meta = this.constructor.metaForProperty(key) || {};
|
||||||
|
if (typeof meta.catch === 'function') {
|
||||||
|
if (typeof _model[`${PREFIX}${key}`].addEventListener === 'function') {
|
||||||
|
this.listen(_model[`_${key}`], 'error', meta.catch.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_model[key] = model[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this._super(_model);
|
||||||
|
},
|
||||||
reset: function(exiting) {
|
reset: function(exiting) {
|
||||||
if (exiting) {
|
if (exiting) {
|
||||||
Object.keys(this).forEach(prop => {
|
Object.keys(this).forEach(prop => {
|
||||||
if (this[prop] && typeof this[prop].close === 'function') {
|
if (this[prop] && typeof this[prop].close === 'function') {
|
||||||
this[prop].close();
|
this[prop].close();
|
||||||
// ember doesn't delete on 'resetController' by default
|
// ember doesn't delete on 'resetController' by default
|
||||||
|
// right now we only call reset when we are exiting, therefore a full
|
||||||
|
// setProperties will be called the next time we enter the Route so this
|
||||||
|
// is ok for what we need and means that the above conditional works
|
||||||
|
// as expected (see 'here' comment above)
|
||||||
delete this[prop];
|
delete this[prop];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -14,3 +40,6 @@ export default Mixin.create({
|
||||||
return this._super(...arguments);
|
return this._super(...arguments);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
export const listen = purify(catchable, function(props) {
|
||||||
|
return props.map(item => `${PREFIX}${item}`);
|
||||||
|
});
|
||||||
|
|
|
@ -30,6 +30,7 @@ export default Model.extend({
|
||||||
Node: attr(),
|
Node: attr(),
|
||||||
Service: attr(),
|
Service: attr(),
|
||||||
Checks: attr(),
|
Checks: attr(),
|
||||||
|
meta: attr(),
|
||||||
passing: computed('ChecksPassing', 'Checks', function() {
|
passing: computed('ChecksPassing', 'Checks', function() {
|
||||||
let num = 0;
|
let num = 0;
|
||||||
// TODO: use typeof
|
// TODO: use typeof
|
||||||
|
|
|
@ -15,14 +15,6 @@ export default Route.extend({
|
||||||
const repo = get(this, 'repo');
|
const repo = get(this, 'repo');
|
||||||
return hash({
|
return hash({
|
||||||
item: repo.findBySlug(params.name, this.modelFor('dc').dc.Name),
|
item: repo.findBySlug(params.name, this.modelFor('dc').dc.Name),
|
||||||
}).then(function(model) {
|
|
||||||
return {
|
|
||||||
...model,
|
|
||||||
...{
|
|
||||||
// Nodes happen to be the ServiceInstances here
|
|
||||||
items: model.item.Nodes,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
setupController: function(controller, model) {
|
setupController: function(controller, model) {
|
||||||
|
|
|
@ -15,15 +15,6 @@ export default Serializer.extend({
|
||||||
const headers = payload[HTTP_HEADERS_SYMBOL] || {};
|
const headers = payload[HTTP_HEADERS_SYMBOL] || {};
|
||||||
delete payload[HTTP_HEADERS_SYMBOL];
|
delete payload[HTTP_HEADERS_SYMBOL];
|
||||||
const normalizedPayload = this.normalizePayload(payload, id, requestType);
|
const normalizedPayload = this.normalizePayload(payload, id, requestType);
|
||||||
const response = this._super(
|
|
||||||
store,
|
|
||||||
primaryModelClass,
|
|
||||||
{
|
|
||||||
[primaryModelClass.modelName]: normalizedPayload,
|
|
||||||
},
|
|
||||||
id,
|
|
||||||
requestType
|
|
||||||
);
|
|
||||||
// put the meta onto the response, here this is ok
|
// put the meta onto the response, here this is ok
|
||||||
// as JSON-API allows this and our specific data is now in
|
// as JSON-API allows this and our specific data is now in
|
||||||
// response[primaryModelClass.modelName]
|
// response[primaryModelClass.modelName]
|
||||||
|
@ -31,7 +22,7 @@ export default Serializer.extend({
|
||||||
// (which was the reason for the Symbol-like property earlier)
|
// (which was the reason for the Symbol-like property earlier)
|
||||||
// use a method modelled on ember-data methods so we have the opportunity to
|
// use a method modelled on ember-data methods so we have the opportunity to
|
||||||
// do this on a per-model level
|
// do this on a per-model level
|
||||||
response.meta = this.normalizeMeta(
|
const meta = this.normalizeMeta(
|
||||||
store,
|
store,
|
||||||
primaryModelClass,
|
primaryModelClass,
|
||||||
headers,
|
headers,
|
||||||
|
@ -39,7 +30,19 @@ export default Serializer.extend({
|
||||||
id,
|
id,
|
||||||
requestType
|
requestType
|
||||||
);
|
);
|
||||||
return response;
|
if (requestType === 'queryRecord') {
|
||||||
|
normalizedPayload.meta = meta;
|
||||||
|
}
|
||||||
|
return this._super(
|
||||||
|
store,
|
||||||
|
primaryModelClass,
|
||||||
|
{
|
||||||
|
meta: meta,
|
||||||
|
[primaryModelClass.modelName]: normalizedPayload,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
requestType
|
||||||
|
);
|
||||||
},
|
},
|
||||||
normalizeMeta: function(store, primaryModelClass, headers, payload, id, requestType) {
|
normalizeMeta: function(store, primaryModelClass, headers, payload, id, requestType) {
|
||||||
const meta = {
|
const meta = {
|
||||||
|
|
|
@ -8,6 +8,18 @@ export default RepositoryService.extend({
|
||||||
findBySlug: function(slug, dc) {
|
findBySlug: function(slug, dc) {
|
||||||
return this._super(...arguments).then(function(item) {
|
return this._super(...arguments).then(function(item) {
|
||||||
const nodes = get(item, 'Nodes');
|
const nodes = get(item, 'Nodes');
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
// TODO: Add an store.error("404", "message") or similar
|
||||||
|
// or move all this to serializer
|
||||||
|
const e = new Error();
|
||||||
|
e.errors = [
|
||||||
|
{
|
||||||
|
status: '404',
|
||||||
|
title: 'Not found',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
const service = get(nodes, 'firstObject');
|
const service = get(nodes, 'firstObject');
|
||||||
const tags = nodes
|
const tags = nodes
|
||||||
.reduce(function(prev, item) {
|
.reduce(function(prev, item) {
|
||||||
|
@ -16,6 +28,7 @@ export default RepositoryService.extend({
|
||||||
.uniq();
|
.uniq();
|
||||||
set(service, 'Tags', tags);
|
set(service, 'Tags', tags);
|
||||||
set(service, 'Nodes', nodes);
|
set(service, 'Nodes', nodes);
|
||||||
|
set(service, 'meta', get(item, 'meta'));
|
||||||
return service;
|
return service;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -36,6 +49,7 @@ export default RepositoryService.extend({
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
// TODO: Add an store.error("404", "message") or similar
|
// TODO: Add an store.error("404", "message") or similar
|
||||||
|
// or move all this to serializer
|
||||||
const e = new Error();
|
const e = new Error();
|
||||||
e.errors = [
|
e.errors = [
|
||||||
{
|
{
|
||||||
|
|
7
ui-v2/app/templates/dc/services/-notifications.hbs
Normal file
7
ui-v2/app/templates/dc/services/-notifications.hbs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{{#if (eq type 'update')}}
|
||||||
|
{{#if (eq status 'warning') }}
|
||||||
|
This service has been deregistered and no longer exists in the catalog.
|
||||||
|
{{else}}
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
{{#app-view class="service list"}}
|
{{#app-view class="service list"}}
|
||||||
{{!TODO: Look at the item passed through to figure what partial to show, also move into its own service partial, for the moment keeping here for visibility}}
|
|
||||||
{{#block-slot 'notification' as |status type|}}
|
{{#block-slot 'notification' as |status type|}}
|
||||||
{{partial 'dc/acls/notifications'}}
|
{{partial 'dc/services/notifications'}}
|
||||||
{{/block-slot}}
|
{{/block-slot}}
|
||||||
{{#block-slot 'header'}}
|
{{#block-slot 'header'}}
|
||||||
<h1>
|
<h1>
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
{{#app-view class="service show"}}
|
{{#app-view class="service show"}}
|
||||||
|
{{#block-slot 'notification' as |status type|}}
|
||||||
|
{{partial 'dc/services/notifications'}}
|
||||||
|
{{/block-slot}}
|
||||||
{{#block-slot 'breadcrumbs'}}
|
{{#block-slot 'breadcrumbs'}}
|
||||||
<ol>
|
<ol>
|
||||||
<li><a data-test-back href={{href-to 'dc.services'}}>All Services</a></li>
|
<li><a data-test-back href={{href-to 'dc.services'}}>All Services</a></li>
|
||||||
|
|
|
@ -27,7 +27,9 @@ module.exports = function(environment) {
|
||||||
injectionFactories: ['view', 'controller', 'component'],
|
injectionFactories: ['view', 'controller', 'component'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// TODO: These should probably go onto APP
|
||||||
ENV = Object.assign({}, ENV, {
|
ENV = Object.assign({}, ENV, {
|
||||||
|
CONSUL_UI_DISABLE_REALTIME: false,
|
||||||
CONSUL_GIT_SHA: (function() {
|
CONSUL_GIT_SHA: (function() {
|
||||||
if (process.env.CONSUL_GIT_SHA) {
|
if (process.env.CONSUL_GIT_SHA) {
|
||||||
return process.env.CONSUL_GIT_SHA;
|
return process.env.CONSUL_GIT_SHA;
|
||||||
|
|
|
@ -10,8 +10,8 @@ Feature: dc / list-blocking
|
||||||
consul:client:
|
consul:client:
|
||||||
blocking: 1
|
blocking: 1
|
||||||
---
|
---
|
||||||
Scenario:
|
Scenario: Viewing the listing pages
|
||||||
And 3 [Model] models
|
Given 3 [Model] models
|
||||||
And a network latency of 100
|
And a network latency of 100
|
||||||
When I visit the [Page] page for yaml
|
When I visit the [Page] page for yaml
|
||||||
---
|
---
|
||||||
|
@ -26,8 +26,31 @@ Feature: dc / list-blocking
|
||||||
And an external edit results in 0 [Model] models
|
And an external edit results in 0 [Model] models
|
||||||
And pause until I see 0 [Model] models
|
And pause until I see 0 [Model] models
|
||||||
Where:
|
Where:
|
||||||
--------------------------------------------
|
------------------------------------------------
|
||||||
| Page | Model | Url |
|
| Page | Model | Url |
|
||||||
| services | service | services |
|
| services | service | services |
|
||||||
| nodes | node | nodes |
|
| nodes | node | nodes |
|
||||||
--------------------------------------------
|
------------------------------------------------
|
||||||
|
Scenario: Viewing detail pages with a listing
|
||||||
|
Given 3 [Model] models
|
||||||
|
And a network latency of 100
|
||||||
|
When I visit the [Page] page for yaml
|
||||||
|
---
|
||||||
|
dc: dc-1
|
||||||
|
service: service-0
|
||||||
|
---
|
||||||
|
Then the url should be /dc-1/[Url]
|
||||||
|
And pause until I see 3 [Model] models
|
||||||
|
And an external edit results in 5 [Model] models
|
||||||
|
And pause until I see 5 [Model] models
|
||||||
|
And an external edit results in 1 [Model] model
|
||||||
|
And pause until I see 1 [Model] model
|
||||||
|
And an external edit results in 0 [Model] models
|
||||||
|
And pause for 300
|
||||||
|
And I see the text "deregistered" in "[data-notification]"
|
||||||
|
Where:
|
||||||
|
------------------------------------------------
|
||||||
|
| Page | Model | Url |
|
||||||
|
| service | instance | services/service-0 |
|
||||||
|
------------------------------------------------
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ export default function(type, count, obj) {
|
||||||
key = 'CONSUL_SERVICE_COUNT';
|
key = 'CONSUL_SERVICE_COUNT';
|
||||||
break;
|
break;
|
||||||
case 'node':
|
case 'node':
|
||||||
|
case 'instance':
|
||||||
key = 'CONSUL_NODE_COUNT';
|
key = 'CONSUL_NODE_COUNT';
|
||||||
break;
|
break;
|
||||||
case 'kv':
|
case 'kv':
|
||||||
|
|
|
@ -5,6 +5,7 @@ export default function(type) {
|
||||||
requests = ['/v1/catalog/datacenters'];
|
requests = ['/v1/catalog/datacenters'];
|
||||||
break;
|
break;
|
||||||
case 'service':
|
case 'service':
|
||||||
|
case 'instance':
|
||||||
requests = ['/v1/internal/ui/services', '/v1/health/service/'];
|
requests = ['/v1/internal/ui/services', '/v1/health/service/'];
|
||||||
break;
|
break;
|
||||||
case 'proxy':
|
case 'proxy':
|
||||||
|
|
|
@ -68,6 +68,10 @@ test('findBySlug returns the correct data for item endpoint', function(assert) {
|
||||||
const service = payload.Nodes[0];
|
const service = payload.Nodes[0];
|
||||||
service.Nodes = nodes;
|
service.Nodes = nodes;
|
||||||
service.Tags = payload.Nodes[0].Service.Tags;
|
service.Tags = payload.Nodes[0].Service.Tags;
|
||||||
|
service.meta = {
|
||||||
|
date: undefined,
|
||||||
|
cursor: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
return service;
|
return service;
|
||||||
})
|
})
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit';
|
||||||
|
|
||||||
moduleFor('controller:dc/services/show', 'Unit | Controller | dc/services/show', {
|
moduleFor('controller:dc/services/show', 'Unit | Controller | dc/services/show', {
|
||||||
// Specify the other units that are required for this test.
|
// Specify the other units that are required for this test.
|
||||||
needs: ['service:search', 'service:dom'],
|
needs: ['service:search', 'service:dom', 'service:flashMessages'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replace this with your real tests.
|
// Replace this with your real tests.
|
||||||
|
|
Loading…
Reference in a new issue