ui: HealthCheck Search/Sort/Filtering (#9314)

* Adds model layer changes around HealthChecks

1. Makes a HealthCheck model fragment and uses it in ServiceInstances and
Nodes
2. Manually adds a relationship between a ServiceInstance and its
potential ServiceInstanceProxy
3. Misc changes related to the above such as an Exposed property on
MeshChecks, MeshChecks itself

* Add a potential temporary endpoint to distinguish ProxyServiceInstance

* Fix up Node search bar class

* Add search/sort/filter logic

* Fixup Service default sort key

* Add Healthcheck search/sort/filtering

* Tweak CSS add a default Type of 'Serf' when type is blank

* Fix up tests and new test support

* Add ability to search on Service/Node name depending on where you are

* Fixup CheckID search predicate

* Use computed for DataCollection to use caching

* Alpha sort the Type menu

* Temporary fix for new non-changing style Ember Proxys

* Only special case EventSource proxies
This commit is contained in:
John Cowen 2020-12-07 09:14:30 +00:00 committed by GitHub
parent f727023b18
commit 23adad684b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 558 additions and 94 deletions

View File

@ -10,7 +10,7 @@
<h3>{{item.Name}}</h3>
</header>
<dl>
{{#if (eq item.ServiceName "")}}
{{#if (eq item.Kind "node")}}
<dt>NodeName</dt>
<dd>{{item.Node}}</dd>
{{else}}
@ -24,9 +24,9 @@
</dl>
<dl>
<dt>Type</dt>
<dd>
{{item.Type}}
{{#if (and @exposed (contains item.Type (array 'http' 'grpc')))}}
<dd data-health-check-type>
{{or item.Type 'serf'}}
{{#if item.Exposed}}
<em
data-test-exposed="true"
{{tooltip "Expose.checks is set to true, so all registered HTTP and gRPC check paths are exposed through Envoy for the Consul agent."}}

View File

@ -46,6 +46,7 @@
width: 50%;
}
%healthcheck-output dl:last-of-type {
margin-top: 1em;
margin-bottom: 0;
}
%healthcheck-output dl:last-of-type dt {

View File

@ -0,0 +1,10 @@
export default (collection, text) => (scope = '.consul-health-check-list') => {
return {
scope,
item: collection('li', {
name: text('header h3'),
type: text('[data-health-check-type]'),
exposed: text('[data-test-exposed]'),
}),
};
};

View File

@ -0,0 +1,140 @@
<form
class="consul-health-check-search-bar filter-bar"
...attributes
>
<div class="search">
<FreetextFilter
@onsearch={{action @onsearch}}
@value={{@search}}
@placeholder="Search"
>
<PopoverSelect
class="type-search-properties"
@position="right"
@onchange={{action @onfilter.searchproperty}}
@multiple={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
Search across
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
{{#each @searchproperties as |prop|}}
<Option @value={{prop}} @selected={{contains prop @filter.searchproperties}}>{{prop}}</Option>
{{/each}}
{{/let}}
</BlockSlot>
</PopoverSelect>
</FreetextFilter>
</div>
<div class="filters">
<PopoverSelect
class="type-status"
@position="left"
@onchange={{action @onfilter.status}}
@multiple={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
Health Status
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Option class="value-passing" @value="passing" @selected={{contains 'passing' @filter.statuses}}>Passing</Option>
<Option class="value-warning" @value="warning" @selected={{contains 'warning' @filter.statuses}}>Warning</Option>
<Option class="value-critical" @value="critical" @selected={{contains 'critical' @filter.statuses}}>Failing</Option>
<Option class="value-empty" @value="empty" @selected={{contains 'empty' @filter.statuses}}>No checks</Option>
{{/let}}
</BlockSlot>
</PopoverSelect>
<PopoverSelect
class="type-kind"
@position="left"
@onchange={{action @onfilter.kind}}
@multiple={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
Kind
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Option @value="service" @selected={{contains 'service' @filter.kinds}}>Service Check</Option>
<Option @value="node" @selected={{contains 'node' @filter.kinds}}>Node Check</Option>
{{/let}}
</BlockSlot>
</PopoverSelect>
<PopoverSelect
class="type-check"
@position="left"
@onchange={{action @onfilter.check}}
@multiple={{true}}
as |components|>
<BlockSlot @name="selected">
<span>
Type
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Option @value="alias" @selected={{contains 'alias' @filter.checks}}>alias</Option>
<Option @value="docker" @selected={{contains 'docker' @filter.checks}}>Docker</Option>
<Option @value="grpc" @selected={{contains 'grpc' @filter.checks}}>gRPC</Option>
<Option @value="http" @selected={{contains 'http' @filter.checks}}>HTTP</Option>
<Option @value="serf" @selected={{contains 'serf' @filter.checks}}>Serf</Option>
<Option @value="tcp" @selected={{contains 'tcp' @filter.checks}}>TCP</Option>
<Option @value="ttl" @selected={{contains 'ttl' @filter.checks}}>TTL</Option>
{{/let}}
</BlockSlot>
</PopoverSelect>
</div>
<div class="sort">
<PopoverSelect
class="type-sort"
data-test-sort-control
@position="right"
@onchange={{action @onsort}}
@multiple={{false}}
as |components|>
<BlockSlot @name="selected">
<span>
{{#let (from-entries (array
(array "Name:asc" "A to Z")
(array "Name:desc" "Z to A")
(array "Status:asc" "Unhealthy to Healthy")
(array "Status:desc" "Healthy to Unhealthy")
(array "Kind:asc" "Service to Node")
(array "Kind:desc" "Node to Service")
))
as |selectable|
}}
{{get selectable @sort}}
{{/let}}
</span>
</BlockSlot>
<BlockSlot @name="options">
{{#let components.Optgroup components.Option as |Optgroup Option|}}
<Optgroup @label="Health Status">
<Option @value="Status:asc" @selected={{eq "Status:asc" @sort}}>Unhealthy to Healthy</Option>
<Option @value="Status:desc" @selected={{eq "Status:desc" @sort}}>Healthy to Unhealthy</Option>
</Optgroup>
<Optgroup @label="Check Name">
<Option @value="Name:asc" @selected={{eq "Name:asc" @sort}}>A to Z</Option>
<Option @value="Name:desc" @selected={{eq "Name:desc" @sort}}>Z to A</Option>
</Optgroup>
<Optgroup @label="Check Type">
<Option @value="Kind:asc" @selected={{eq "Kind:asc" @sort}}>Service to Node</Option>
<Option @value="Kind:desc" @selected={{eq "Kind:desc" @sort}}>Node to Service</Option>
</Optgroup>
{{/let}}
</BlockSlot>
</PopoverSelect>
</div>
</form>

View File

@ -1,5 +1,5 @@
<form
class="consul-node-list filter-bar"
class="consul-node-search-bar filter-bar"
...attributes
>
<div class="search">

View File

@ -1,5 +1,6 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { computed } from '@ember/object';
import { sort } from '@ember/object/computed';
import { defineProperty } from '@ember/object';
@ -12,6 +13,18 @@ export default class DataCollectionComponent extends Component {
return this.args.type;
}
@computed('args.items', 'args.items.content')
get content() {
// TODO: Temporary little hack to ensure we detect DataSource proxy
// objects but not any other special Ember Proxy object like ember-data
// things. Remove this once we no longer need the Proxies
if (this.args.items.dispatchEvent === 'function') {
return this.args.items.content;
}
return this.args.items;
}
@computed('comparator', 'searched')
get items() {
// the ember sort computed accepts either:
// 1. The name of a property (as a string) returning an array properties to sort by
@ -24,6 +37,7 @@ export default class DataCollectionComponent extends Component {
return this.sorted;
}
@computed('type', 'filtered', 'args.filters.searchproperties', 'args.search')
get searched() {
if (typeof this.args.search === 'undefined') {
return this.filtered;
@ -36,17 +50,19 @@ export default class DataCollectionComponent extends Component {
return this.filtered.filter(predicate(this.args.search, options));
}
@computed('type', 'content', 'args.filters')
get filtered() {
if (typeof this.args.filters === 'undefined') {
return this.args.items;
return this.content;
}
const predicate = this.filter.predicate(this.type);
if (typeof predicate === 'undefined') {
return this.args.items;
return this.content;
}
return this.args.items.filter(predicate(this.args.filters));
return this.content.filter(predicate(this.args.filters));
}
@computed('type', 'args.sort')
get comparator() {
if (typeof this.args.sort === 'undefined') {
return [];

View File

@ -0,0 +1,21 @@
export default {
statuses: {
passing: (item, value) => item.Status === value,
warning: (item, value) => item.Status === value,
critical: (item, value) => item.Status === value,
},
kinds: {
service: (item, value) => item.Kind === value,
node: (item, value) => item.Kind === value,
},
checks: {
serf: (item, value) => item.Type === '',
script: (item, value) => item.Type === value,
http: (item, value) => item.Type === value,
tcp: (item, value) => item.Type === value,
ttl: (item, value) => item.Type === value,
docker: (item, value) => item.Type === value,
grpc: (item, value) => item.Type === value,
alias: (item, value) => item.Type === value,
},
};

View File

@ -0,0 +1,41 @@
import Fragment from 'ember-data-model-fragments/fragment';
import { array } from 'ember-data-model-fragments/attributes';
import { attr } from '@ember-data/model';
import { computed } from '@ember/object';
export const schema = {
Status: {
allowedValues: ['passing', 'warning', 'critical'],
},
Type: {
allowedValues: ['', 'script', 'http', 'tcp', 'ttl', 'docker', 'grpc', 'alias'],
},
};
export default class HealthCheck extends Fragment {
@attr('string') Name;
@attr('string') CheckID;
@attr('string') Type;
@attr('string') Status;
@attr('string') Notes;
@attr('string') Output;
@attr('string') ServiceName;
@attr('string') ServiceID;
@attr('string') Node;
@array('string') ServiceTags;
@attr() Definition; // {}
// Exposed is only set correct if this Check is accessed via instance.MeshChecks
// essentially this is a lazy MeshHealthCheckModel
@attr('boolean') Exposed;
@computed('ServiceID')
get Kind() {
return this.ServiceID === '' ? 'node' : 'service';
}
@computed('Type')
get Exposable() {
return ['http', 'grpc'].includes(this.Type);
}
}

View File

@ -1,5 +1,6 @@
import Model, { attr } from '@ember-data/model';
import { computed } from '@ember/object';
import { fragmentArray } from 'ember-data-model-fragments/attributes';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'ID';
@ -18,7 +19,7 @@ export default class Node extends Model {
@attr() Meta; // {}
@attr() TaggedAddresses; // {lan, wan}
@attr() Services; // ServiceInstances[]
@attr() Checks; // Checks[]
@fragmentArray('health-check') Checks;
@computed('Checks.[]', 'ChecksCritical', 'ChecksPassing', 'ChecksWarning')
get Status() {

View File

@ -1,10 +1,11 @@
import Model, { attr } from '@ember-data/model';
import ServiceInstanceModel from './service-instance';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'Node,ServiceID';
// TODO: This should be changed to ProxyInstance
export default class Proxy extends Model {
export default class Proxy extends ServiceInstanceModel {
@attr('string') uid;
@attr('string') ID;

View File

@ -1,5 +1,6 @@
import Model, { attr, belongsTo } from '@ember-data/model';
import { computed } from '@ember/object';
import { fragmentArray } from 'ember-data-model-fragments/attributes';
import { computed, get, set } from '@ember/object';
import { or, filter, alias } from '@ember/object/computed';
export const PRIMARY_KEY = 'uid';
@ -15,7 +16,7 @@ export default class ServiceInstance extends Model {
@attr() Proxy;
@attr() Node;
@attr() Service;
@attr() Checks;
@fragmentArray('health-check') Checks;
@attr('number') SyncTime;
@attr() meta;
@ -29,8 +30,35 @@ export default class ServiceInstance extends Model {
@alias('Service.Tags') Tags;
@alias('Service.Meta') Meta;
@alias('Service.Namespace') Namespace;
@filter('Checks.[]', (item, i, arr) => item.ServiceID !== '') ServiceChecks;
@filter('Checks.[]', (item, i, arr) => item.ServiceID === '') NodeChecks;
@filter('Checks.@each.Kind', (item, i, arr) => item.Kind === 'service') ServiceChecks;
@filter('Checks.@each.Kind', (item, i, arr) => item.Kind === 'node') NodeChecks;
// MeshChecks are a concatenation of Checks for the Instance and Checks for
// the ProxyInstance. Checks is an ember-data-model-fragment, so we can't just
// concat it, we have to loop through all the items in order to merge
@computed('Checks', 'ProxyInstance.Checks', 'ProxyInstance.ServiceProxy.Expose.Checks')
get MeshChecks() {
return (get(this, 'Checks') || [])
.map(item => {
set(
item,
'Exposed',
get(this, 'ProxyInstance.ServiceProxy.Expose.Checks') && get(item, 'Exposable')
);
return item;
})
.concat(
(get(this, 'ProxyInstance.Checks') || []).map(item => {
set(
item,
'Exposed',
get(this, 'ProxyInstance.ServiceProxy.Expose.Checks') && get(item, 'Exposable')
);
return item;
})
);
}
@computed('Service.Meta')
get ExternalSources() {

View File

@ -1,12 +1,30 @@
import Route from 'consul-ui/routing/route';
export default class HealthchecksRoute extends Route {
queryParams = {
sortBy: 'sort',
status: 'status',
kind: 'kind',
check: 'check',
searchproperty: {
as: 'searchproperty',
empty: [['Name', 'Service', 'CheckID', 'Notes', 'Output', 'ServiceTags']],
},
search: {
as: 'filter',
replace: true,
},
};
model() {
const parent = this.routeName
.split('.')
.slice(0, -1)
.join('.');
return this.modelFor(parent);
return {
...this.modelFor(parent),
searchProperties: this.queryParams.searchproperty.empty[0],
};
}
setupController(controller, model) {

View File

@ -29,7 +29,7 @@ export default class InstanceRoute extends Route {
// the proxy itself is just a normal service model
proxy = await this.data.source(
uri =>
uri`/${nspace}/${dc}/service-instance/${proxyParams.id}/${proxyParams.node}/${proxyParams.name}`
uri`/${nspace}/${dc}/proxy-service-instance/${proxyParams.id}/${proxyParams.node}/${proxyParams.name}`
);
}
}

View File

@ -1,12 +1,30 @@
import Route from 'consul-ui/routing/route';
export default class HealthchecksRoute extends Route {
queryParams = {
sortBy: 'sort',
status: 'status',
kind: 'kind',
check: 'check',
searchproperty: {
as: 'searchproperty',
empty: [['Name', 'Node', 'CheckID', 'Notes', 'Output', 'ServiceTags']],
},
search: {
as: 'filter',
replace: true,
},
};
model() {
const parent = this.routeName
.split('.')
.slice(0, -1)
.join('.');
return this.modelFor(parent);
return {
...this.modelFor(parent),
searchProperties: this.queryParams.searchproperty.empty[0],
};
}
setupController(controller, model) {

View File

@ -0,0 +1,32 @@
const asArray = function(arr) {
return Array.isArray(arr) ? arr : arr.toArray();
};
export default {
Name: (item, value) => {
return item.Name.toLowerCase().indexOf(value.toLowerCase()) !== -1;
},
Node: (item, value) => {
return item.Node.toLowerCase().indexOf(value.toLowerCase()) !== -1;
},
Service: (item, value) => {
const lower = value.toLowerCase();
return (
item.ServiceName.toLowerCase().indexOf(lower) !== -1 ||
item.ServiceID.toLowerCase().indexOf(lower) !== -1
);
},
CheckID: (item, value) => (item.CheckID || '').toLowerCase().indexOf(value.toLowerCase()) !== -1,
Notes: (item, value) =>
item.Notes.toString()
.toLowerCase()
.indexOf(value.toLowerCase()) !== -1,
Output: (item, value) =>
item.Output.toString()
.toLowerCase()
.indexOf(value.toLowerCase()) !== -1,
ServiceTags: (item, value) => {
return asArray(item.ServiceTags || []).some(
item => item.toLowerCase().indexOf(value.toLowerCase()) !== -1
);
},
};

View File

@ -26,6 +26,9 @@ export default class HttpService extends Service {
@service('repository/service-instance')
'service-instance';
@service('repository/service-instance')
'proxy-service-instance';
@service('repository/service-instance')
'service-instances';
@ -192,6 +195,11 @@ export default class HttpService extends Service {
find = configuration =>
repo.findBySlug(rest[0], rest[1], rest[2], dc, nspace, configuration);
break;
case 'proxy-service-instance':
// id, node, service
find = configuration =>
repo.findProxyBySlug(rest[0], rest[1], rest[2], dc, nspace, configuration);
break;
case 'proxy-instance':
// id, node, service
find = configuration =>

View File

@ -4,6 +4,7 @@ import { andOr } from 'consul-ui/utils/filter';
import acl from 'consul-ui/filter/predicates/acl';
import service from 'consul-ui/filter/predicates/service';
import serviceInstance from 'consul-ui/filter/predicates/service-instance';
import healthCheck from 'consul-ui/filter/predicates/health-check';
import node from 'consul-ui/filter/predicates/node';
import kv from 'consul-ui/filter/predicates/kv';
import intention from 'consul-ui/filter/predicates/intention';
@ -14,6 +15,7 @@ const predicates = {
acl: andOr(acl),
service: andOr(service),
['service-instance']: andOr(serviceInstance),
['health-check']: andOr(healthCheck),
node: andOr(node),
kv: andOr(kv),
intention: andOr(intention),

View File

@ -1,11 +1,15 @@
import RepositoryService from 'consul-ui/services/repository';
import { inject as service } from '@ember/service';
import { set, get } from '@ember/object';
const modelName = 'service-instance';
export default class ServiceInstanceService extends RepositoryService {
@service('repository/proxy') proxyRepo;
getModelName() {
return modelName;
}
findByService(slug, dc, nspace, configuration = {}) {
async findByService(slug, dc, nspace, configuration = {}) {
const query = {
dc: dc,
ns: nspace,
@ -18,7 +22,7 @@ export default class ServiceInstanceService extends RepositoryService {
return this.store.query(this.getModelName(), query);
}
findBySlug(serviceId, node, service, dc, nspace, configuration = {}) {
async findBySlug(serviceId, node, service, dc, nspace, configuration = {}) {
const query = {
dc: dc,
ns: nspace,
@ -32,4 +36,27 @@ export default class ServiceInstanceService extends RepositoryService {
}
return this.store.queryRecord(this.getModelName(), query);
}
async findProxyBySlug(serviceId, node, service, dc, nspace, configuration = {}) {
const instance = await this.findBySlug(...arguments);
let proxy = this.store.peekRecord('proxy', instance.uid);
// if(typeof proxy === 'undefined') {
// await proxyRepo.create({})
// }
// Copy over all the things to the ProxyServiceInstance
['Service', 'Node'].forEach(prop => {
set(proxy, prop, instance[prop]);
});
['Checks'].forEach(prop => {
instance[prop].forEach(item => {
if (typeof item !== 'undefined') {
proxy[prop].addFragment(item.copy());
}
});
});
// delete the ServiceInstance record as we now have a ProxyServiceInstance
instance.unloadRecord();
return proxy;
}
}

View File

@ -4,6 +4,7 @@ import setHelpers from 'mnemonist/set';
import intention from 'consul-ui/search/predicates/intention';
import upstreamInstance from 'consul-ui/search/predicates/upstream-instance';
import serviceInstance from 'consul-ui/search/predicates/service-instance';
import healthCheck from 'consul-ui/search/predicates/health-check';
import acl from 'consul-ui/search/predicates/acl';
import service from 'consul-ui/search/predicates/service';
import node from 'consul-ui/search/predicates/node';
@ -47,6 +48,7 @@ const predicates = {
service: search(service),
['service-instance']: search(serviceInstance),
['upstream-instance']: upstreamInstance(),
['health-check']: search(healthCheck),
node: search(node),
kv: search(kv),
acl: search(acl),

View File

@ -4,7 +4,7 @@ import serviceInstance from 'consul-ui/sort/comparators/service-instance';
import upstreamInstance from 'consul-ui/sort/comparators/upstream-instance';
import acl from 'consul-ui/sort/comparators/acl';
import kv from 'consul-ui/sort/comparators/kv';
import check from 'consul-ui/sort/comparators/check';
import healthCheck from 'consul-ui/sort/comparators/health-check';
import intention from 'consul-ui/sort/comparators/intention';
import token from 'consul-ui/sort/comparators/token';
import role from 'consul-ui/sort/comparators/role';
@ -31,9 +31,9 @@ const comparators = {
service: service(options),
['service-instance']: serviceInstance(options),
['upstream-instance']: upstreamInstance(options),
['health-check']: healthCheck(options),
acl: acl(options),
kv: kv(options),
check: check(options),
intention: intention(options),
token: token(options),
role: role(options),

View File

@ -1,4 +1,4 @@
export default () => key => {
export default ({ properties }) => (key = 'Status:asc') => {
if (key.startsWith('Status:')) {
return function(itemA, itemB) {
const [, dir] = key.split(':');
@ -38,5 +38,5 @@ export default () => key => {
return 0;
};
}
return key;
return properties(['Name', 'Kind'])(key);
};

View File

@ -1,4 +1,4 @@
export default ({ properties }) => (key = 'Name:asc') => {
export default ({ properties }) => (key = 'Status:asc') => {
if (key.startsWith('Status:')) {
return function(serviceA, serviceB) {
const [, dir] = key.split(':');

View File

@ -9,7 +9,8 @@ html[data-route$='edit'] .app-view > header + div > *:first-child {
/* if it is a filter bar and the thing after the filter bar is a p then it also */
/* needs a top margun :S */
%app-view-content [role='tabpanel'] > *:first-child:not(.filter-bar):not(table),
%app-view-content [role='tabpanel'] > .filter-bar + p {
%app-view-content [role='tabpanel'] > .filter-bar + p,
%app-view-content [role='tabpanel'] .consul-health-check-list {
margin-top: 1.25em;
}
.consul-upstream-instance-list,

View File

@ -1,15 +1,58 @@
<div id="health-checks" class="tab-section">
<div role="tabpanel">
{{#if (gt item.Checks.length 0) }}
<Consul::HealthCheck::List @items={{sort-by (comparator 'check' 'Status:asc') item.Checks}} />
{{else}}
<EmptyState>
<BlockSlot @name="body">
<p>
This node has no health checks.
</p>
</BlockSlot>
</EmptyState>
{{/if}}
</div>
</div>
{{#let (hash
statuses=(if status (split status ',') undefined)
kinds=(if kind (split kind ',') undefined)
checks=(if check (split check ',') undefined)
searchproperties=(if (not-eq searchproperty undefined)
(split searchproperty ',')
searchProperties
)
) as |filters|}}
{{#let (or sortBy "Status:asc") as |sort|}}
<div class="tab-section">
<div role="tabpanel">
{{#if (gt item.Checks.length 0) }}
<input type="checkbox" id="toolbar-toggle" />
<Consul::HealthCheck::SearchBar
@search={{search}}
@onsearch={{action (mut search) value="target.value"}}
@searchproperties={{searchProperties}}
@sort={{sort}}
@onsort={{action (mut sortBy) value="target.selected"}}
@filter={{filters}}
@onfilter={{hash
searchproperty=(action (mut searchproperty) value="target.selectedItems")
status=(action (mut status) value="target.selectedItems")
kind=(action (mut kind) value="target.selectedItems")
check=(action (mut check) value="target.selectedItems")
}}
/>
{{/if}}
<DataCollection
@type="health-check"
@sort={{sort}}
@filters={{filters}}
@search={{search}}
@items={{item.Checks}}
as |collection|>
<collection.Collection>
<Consul::HealthCheck::List
@items={{collection.items}}
/>
</collection.Collection>
<collection.Empty>
<EmptyState>
<BlockSlot @name="body">
<p>
This node has no health checks{{#if (gt item.Checks.length 0)}} matching that search{{/if}}.
</p>
</BlockSlot>
</EmptyState>
</collection.Empty>
</DataCollection>
</div>
</div>
{{/let}}
{{/let}}

View File

@ -1,22 +1,60 @@
{{#let (hash
statuses=(if status (split status ',') undefined)
kinds=(if kind (split kind ',') undefined)
checks=(if check (split check ',') undefined)
searchproperties=(if (not-eq searchproperty undefined)
(split searchproperty ',')
searchProperties
)
) as |filters|}}
{{#let (or sortBy "Status:asc") as |sort|}}
<div class="tab-section">
<div role="tabpanel">
{{#let (append item.Checks (or proxy.Checks (array))) as |checks|}}
{{#if (gt checks.length 0) }}
<section data-test-checks>
<Consul::HealthCheck::List
@items={{sort-by (comparator 'check' 'Status:asc') checks}}
@exposed={{proxyMeta.ServiceProxy.Expose.Checks}}
/>
</section>
{{else}}
<EmptyState>
<BlockSlot @name="body">
<p>
This instance has no health checks.
</p>
</BlockSlot>
</EmptyState>
{{#if (gt item.MeshChecks.length 0) }}
<input type="checkbox" id="toolbar-toggle" />
<Consul::HealthCheck::SearchBar
@search={{search}}
@onsearch={{action (mut search) value="target.value"}}
@searchproperties={{searchProperties}}
@sort={{sort}}
@onsort={{action (mut sortBy) value="target.selected"}}
@filter={{filters}}
@onfilter={{hash
searchproperty=(action (mut searchproperty) value="target.selectedItems")
status=(action (mut status) value="target.selectedItems")
kind=(action (mut kind) value="target.selectedItems")
check=(action (mut check) value="target.selectedItems")
}}
/>
{{/if}}
{{/let}}
<DataCollection
@type="health-check"
@sort={{sort}}
@filters={{filters}}
@search={{search}}
@items={{item.MeshChecks}}
as |collection|>
<collection.Collection>
<Consul::HealthCheck::List
@items={{collection.items}}
/>
</collection.Collection>
<collection.Empty>
<EmptyState>
<BlockSlot @name="body">
<p>
This instance has no health checks{{#if (gt item.MeshChecks.length 0)}} matching that search{{/if}}.
</p>
</BlockSlot>
</EmptyState>
</collection.Empty>
</DataCollection>
</div>
</div>
{{/let}}
{{/let}}

View File

@ -28,18 +28,10 @@ test('findByDatacenter returns the correct data for list endpoint', function(ass
return service.findAllByDatacenter(dc);
},
function performAssertion(actual, expected) {
assert.deepEqual(
actual,
expected(function(payload) {
return payload.map(item =>
Object.assign({}, item, {
SyncTime: now,
Datacenter: dc,
uid: `["${nspace}","${dc}","${item.ID}"]`,
})
);
})
);
actual.forEach(item => {
assert.equal(item.uid, `["${nspace}","${dc}","${item.ID}"]`);
assert.equal(item.Datacenter, dc);
});
}
);
});
@ -55,22 +47,8 @@ test('findBySlug returns the correct data for item endpoint', function(assert) {
return service.findBySlug(id, dc);
},
function(actual, expected) {
assert.deepEqual(
actual,
expected(function(payload) {
const item = payload;
return Object.assign({}, item, {
Datacenter: dc,
uid: `["${nspace}","${dc}","${item.ID}"]`,
meta: {
cacheControl: undefined,
cursor: undefined,
dc: dc,
nspace: nspace,
},
});
})
);
assert.equal(actual.uid, `["${nspace}","${dc}","${actual.ID}"]`);
assert.equal(actual.Datacenter, dc);
}
);
});

View File

@ -40,6 +40,7 @@ import popoverSelectFactory from 'consul-ui/components/popover-select/pageobject
import morePopoverMenuFactory from 'consul-ui/components/more-popover-menu/pageobject';
import tokenListFactory from 'consul-ui/components/token-list/pageobject';
import consulHealthCheckListFactory from 'consul-ui/components/consul/health-check/list/pageobject';
import consulUpstreamInstanceListFactory from 'consul-ui/components/consul/upstream-instance/list/pageobject';
import consulTokenListFactory from 'consul-ui/components/consul/token/list/pageobject';
import consulRoleListFactory from 'consul-ui/components/consul/role/list/pageobject';
@ -95,6 +96,7 @@ const morePopoverMenu = morePopoverMenuFactory(clickable);
const popoverSelect = popoverSelectFactory(clickable, collection);
const emptyState = emptyStateFactory(isPresent);
const consulHealthCheckList = consulHealthCheckListFactory(collection, text);
const consulUpstreamInstanceList = consulUpstreamInstanceListFactory(collection, text);
const consulIntentionList = consulIntentionListFactory(
collection,
@ -162,10 +164,30 @@ export default {
service(visitable, attribute, collection, text, consulIntentionList, catalogToolbar, tabgroup)
),
instance: create(
instance(visitable, alias, attribute, collection, text, tabgroup, consulUpstreamInstanceList)
instance(
visitable,
alias,
attribute,
collection,
text,
tabgroup,
consulUpstreamInstanceList,
consulHealthCheckList
)
),
nodes: create(nodes(visitable, text, clickable, attribute, collection, popoverSelect)),
node: create(node(visitable, deletable, clickable, attribute, collection, tabgroup, text)),
node: create(
node(
visitable,
deletable,
clickable,
attribute,
collection,
tabgroup,
text,
consulHealthCheckList
)
),
kvs: create(kvs(visitable, creatable, consulKvList)),
kv: create(kv(visitable, attribute, submitable, deletable, cancelable, clickable)),
acls: create(acls(visitable, deletable, creatable, clickable, attribute, collection, aclFilter)),

View File

@ -1,4 +1,13 @@
export default function(visitable, deletable, clickable, attribute, collection, tabs, text) {
export default function(
visitable,
deletable,
clickable,
attribute,
collection,
tabs,
text,
healthChecks
) {
return {
visit: visitable('/:dc/nodes/:node'),
tabs: tabs('tab', [
@ -8,9 +17,7 @@ export default function(visitable, deletable, clickable, attribute, collection,
'lock-sessions',
'metadata',
]),
healthchecks: collection('[data-test-node-healthcheck]', {
name: attribute('data-test-node-healthcheck'),
}),
healthChecks: healthChecks(),
services: collection('.consul-service-instance-list > ul > li:not(:first-child)', {
name: text('[data-test-service-name]'),
port: attribute('data-test-service-port', '[data-test-service-port]'),

View File

@ -1,17 +1,26 @@
export default function(visitable, alias, attribute, collection, text, tabs, upstreams) {
export default function(
visitable,
alias,
attribute,
collection,
text,
tabs,
upstreams,
healthChecks
) {
return {
visit: visitable('/:dc/services/:service/instances/:node/:id'),
externalSource: attribute('data-test-external-source', '[data-test-external-source]', {
scope: '.title',
}),
tabs: tabs('tab', ['health-checks', 'upstreams', 'exposed-paths', 'addresses', 'tags-&-meta']),
checks: collection('[data-test-checks] li'),
checks: alias('healthChecks.item'),
healthChecks: healthChecks(),
upstreams: alias('upstreamInstances.item'),
upstreamInstances: upstreams(),
exposedPaths: collection('[data-test-proxy-exposed-paths] > tbody tr', {
combinedAddress: text('[data-test-combined-address]'),
}),
proxyChecks: collection('[data-test-proxy-checks] li'),
addresses: collection('#addresses [data-test-tabular-row]', {
address: text('[data-test-address]'),
}),