ui: Leader icon for node listing view (#6265)

- yarn upgrade consul-api-double which includes `status/leader`
- add all the ember-data things required to call a new endpoint
- Pass the new leader variable through to the template
- use the new leader variable in the template to set a leader
- add acceptance testing to verify leaders are highlighted
- Change testing navigation/api requests to status/leader (on the node listing page, status/leader is now the last get request to
be called).
- Template whitespace commit (less indenting)
- adds a test to to assert no errors happen with an unelected leader
This commit is contained in:
John Cowen 2019-08-12 17:43:18 +02:00 committed by John Cowen
parent 7db707c8b3
commit 5cf063fc8a
14 changed files with 217 additions and 73 deletions

View File

@ -20,6 +20,38 @@ export default Adapter.extend({
}
return this.appendURL('internal/ui/node', [query.id], this.cleanQuery(query));
},
urlForRequest: function({ type, snapshot, requestType }) {
switch (requestType) {
case 'queryLeader':
return this.urlForQueryLeader(snapshot, type.modelName);
}
return this._super(...arguments);
},
urlForQueryLeader: function(query, modelName) {
// https://www.consul.io/api/status.html#get-raft-leader
return this.appendURL('status/leader', [], this.cleanQuery(query));
},
isQueryLeader: function(url, method) {
return url.pathname === this.parseURL(this.urlForQueryLeader({})).pathname;
},
queryLeader: function(store, modelClass, id, snapshot) {
const params = {
store: store,
type: modelClass,
id: id,
snapshot: snapshot,
requestType: 'queryLeader',
};
// _requestFor is private... but these methods aren't, until they disappear..
const request = {
method: this.methodForRequest(params),
url: this.urlForRequest(params),
headers: this.headersForRequest(params),
data: this.dataForRequest(params),
};
// TODO: private..
return this._makeRequest(request);
},
handleBatchResponse: function(url, response, primary, slug) {
const dc = url.searchParams.get(API_DATACENTER_KEY) || '';
return response.map((item, i, arr) => {
@ -41,7 +73,21 @@ export default Adapter.extend({
const method = requestData.method;
if (status === HTTP_OK) {
const url = this.parseURL(requestData.url);
let temp, port, address;
switch (true) {
case this.isQueryLeader(url, method):
// This response is just an ip:port like `"10.0.0.1:8000"`
// split it and make it look like a `C`onsul.`R`esponse
// popping off the end for ports should cover us for IPv6 addresses
// as we should always get a `address:port` or `[a:dd:re:ss]:port` combo
temp = response.split(':');
port = temp.pop();
address = temp.join(':');
response = {
Address: address,
Port: port,
};
break;
case this.isQueryRecord(url, method):
response = this.handleSingleResponse(url, fillSlug(response), PRIMARY_KEY, SLUG_KEY);
break;

View File

@ -12,8 +12,10 @@ export default Route.extend({
},
},
model: function(params) {
const dc = this.modelFor('dc').dc.Name;
return hash({
items: get(this, 'repo').findAllByDatacenter(this.modelFor('dc').dc.Name),
items: get(this, 'repo').findAllByDatacenter(dc),
leader: get(this, 'repo').findByLeader(dc),
});
},
setupController: function(controller, model) {

View File

@ -1,9 +1,17 @@
import RepositoryService from 'consul-ui/services/repository';
import { inject as service } from '@ember/service';
import { get } from '@ember/object';
const modelName = 'node';
export default RepositoryService.extend({
coordinates: service('repository/coordinate'),
getModelName: function() {
return modelName;
},
findByLeader: function(dc) {
const query = {
dc: dc,
};
return get(this, 'store').queryLeader(this.getModelName(), query);
},
});

View File

@ -14,7 +14,7 @@ const createProxy = function(repo, find, settings, cache, serialize = JSON.strin
type: 'message',
data: result,
};
const meta = get(event.data || {}, 'meta');
const meta = get(event.data || {}, 'meta') || {};
if (typeof meta.date !== 'undefined') {
// unload anything older than our current sync date/time
store.peekAll(repo.getModelName()).forEach(function(item) {

View File

@ -23,4 +23,9 @@ export default Store.extend({
const adapter = this.adapterFor(modelName);
return adapter.self(this, { modelName: modelName }, token);
},
queryLeader: function(modelName, query) {
// TODO: no normalization, type it properly for the moment
const adapter = this.adapterFor(modelName);
return adapter.queryLeader(this, { modelName: modelName }, null, query);
},
});

View File

@ -2,7 +2,6 @@
.healthchecked-resource > div {
@extend %stats-card;
}
%tooltip-below::after {
top: calc(100% - 8px);
bottom: auto;
@ -12,6 +11,8 @@
%tooltip-below::before {
top: calc(100% + 4px);
bottom: auto;
/*TODO: This should probably go into base*/
line-height: 1em;
}
%tooltip-left::before {
right: 0;
@ -21,8 +22,6 @@
}
%stats-card-icon {
@extend %tooltip-below;
/*TODO: This should probably go into base*/
line-height: 1em;
}
%stats-card-icon:first-child::before {
right: 0;

View File

@ -1,5 +1,5 @@
{{#stats-card}}
{{#block-slot 'icon'}}{{#if false}}<span data-tooltip="Leader">Leader</span>{{/if}}{{/block-slot}}
{{#block-slot 'icon'}}{{yield}}{{/block-slot}}
{{#block-slot 'mini-stat'}}
{{#if (eq checks.length 0)}}
<span class="zero" data-tooltip="This node has no registered healthchecks">{{checks.length}}</span>

View File

@ -1,72 +1,84 @@
{{#app-view class="node list"}}
{{#block-slot 'header'}}
<h1>
Nodes <em>{{format-number items.length}} total</em>
</h1>
<label for="toolbar-toggle"></label>
{{/block-slot}}
{{#block-slot 'toolbar'}}
{{#block-slot 'header'}}
<h1>
Nodes <em>{{format-number items.length}} total</em>
</h1>
<label for="toolbar-toggle"></label>
{{/block-slot}}
{{#block-slot 'toolbar'}}
{{#if (gt items.length 0) }}
{{catalog-filter searchable=(array searchableHealthy searchableUnhealthy) search=s status=filters.status onchange=(action 'filter')}}
{{catalog-filter searchable=(array searchableHealthy searchableUnhealthy) search=s status=filters.status onchange=(action 'filter')}}
{{/if}}
{{/block-slot}}
{{#block-slot 'content'}}
{{/block-slot}}
{{#block-slot 'content'}}
{{#if (gt unhealthy.length 0) }}
<div class="unhealthy">
<h2>Unhealthy Nodes</h2>
<div>
{{! think about 2 differing views here }}
<ul>
{{#changeable-set dispatcher=searchableUnhealthy}}
{{#block-slot 'set' as |unhealthy|}}
{{#each unhealthy as |item|}}
{{healthchecked-resource
tagName='li'
data-test-node=item.Node
href=(href-to 'dc.nodes.show' item.Node)
name=item.Node
address=item.Address
checks=item.Checks
}}
{{/each}}
<div class="unhealthy">
<h2>Unhealthy Nodes</h2>
<div>
{{! think about 2 differing views here }}
<ul>
{{#changeable-set dispatcher=searchableUnhealthy}}
{{#block-slot 'set' as |unhealthy|}}
{{#each unhealthy as |item|}}
{{#healthchecked-resource
tagName='li'
data-test-node=item.Node
href=(href-to 'dc.nodes.show' item.Node)
name=item.Node
address=item.Address
checks=item.Checks
}}
{{#block-slot 'icon'}}
{{#if (eq item.Address leader.Address)}}
<span data-test-leader={{leader.Address}} data-tooltip="Leader">Leader</span>
{{/if}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no unhealthy nodes for that search.
</p>
{{/block-slot}}
{{/changeable-set}}
</ul>
</div>
</div>
{{/if}}
{{#if (gt healthy.length 0) }}
<div class="healthy">
<h2>Healthy Nodes</h2>
{{#changeable-set dispatcher=searchableHealthy}}
{{#block-slot 'set' as |healthy|}}
{{#list-collection cellHeight=92 items=healthy as |item index|}}
{{healthchecked-resource
data-test-node=item.Node
href=(href-to 'dc.nodes.show' item.Node)
name=item.Node
address=item.Address
checks=item.Checks
}}
{{/list-collection}}
{{/healthchecked-resource}}
{{/each}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no healthy nodes for that search.
There are no unhealthy nodes for that search.
</p>
{{/block-slot}}
{{/changeable-set}}
</ul>
</div>
</div>
{{/if}}
{{#if (gt healthy.length 0) }}
<div class="healthy">
<h2>Healthy Nodes</h2>
{{#changeable-set dispatcher=searchableHealthy}}
{{#block-slot 'set' as |healthy|}}
{{#list-collection cellHeight=92 items=healthy as |item index|}}
{{#healthchecked-resource
data-test-node=item.Node
href=(href-to 'dc.nodes.show' item.Node)
name=item.Node
address=item.Address
checks=item.Checks
}}
{{#block-slot 'icon'}}
{{#if (eq item.Address leader.Address)}}
<span data-test-leader={{leader.Address}} data-tooltip="Leader">Leader</span>
{{/if}}
{{/block-slot}}
{{/healthchecked-resource}}
{{/list-collection}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no healthy nodes for that search.
</p>
{{/block-slot}}
{{/changeable-set}}
</div>
{{/if}}
{{#if (and (eq healthy.length 0) (eq unhealthy.length 0)) }}
<p>
There are no nodes.
</p>
<p>
There are no nodes.
</p>
{{/if}}
{{/block-slot}}
{{/block-slot}}
{{/app-view}}

View File

@ -1,11 +1,51 @@
@setupApplicationTest
Feature: Nodes
Scenario:
Feature: dc / nodes / index
Background:
Given 1 datacenter model with the value "dc-1"
And 3 node models
And the url "/v1/status/leader" responds with from yaml
---
body: |
"211.245.86.75:8500"
---
Scenario: Viewing nodes in the listing
Given 3 node models
When I visit the nodes page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/nodes
Then I see 3 node models
Scenario: Seeing the leader in unhealthy listing
Given 3 node models from yaml
---
- Address: 211.245.86.75
Checks:
- Status: warning
Name: Warning check
- Address: 10.0.0.1
- Address: 10.0.0.3
---
When I visit the nodes page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/nodes
Then I see 3 node models
And I see leader on the unHealthyNodes
Scenario: Seeing the leader in healthy listing
Given 3 node models from yaml
---
- Address: 211.245.86.75
Checks:
- Status: passing
Name: Passing check
- Address: 10.0.0.1
- Address: 10.0.0.3
---
When I visit the nodes page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/nodes
Then I see 3 node models
And I see leader on the healthyNodes

View File

@ -0,0 +1,18 @@
@setupApplicationTest
Feature: dc / nodes / no-leader
Scenario: Leader hasn't been elected
Given 1 datacenter model with the value "dc-1"
And 3 node models
And the url "/v1/status/leader" responds with from yaml
---
body: |
""
---
When I visit the nodes page for yaml
---
dc: dc-1
---
Then the url should be /dc-1/nodes
Then I see 3 node models
And I don't see leader on the nodes

View File

@ -23,7 +23,7 @@ Feature: Page Navigation
Where:
-----------------------------------------------------------------------
| Link | URL | Endpoint |
| nodes | /dc-1/nodes | /v1/internal/ui/nodes?dc=dc-1 |
| nodes | /dc-1/nodes | /v1/status/leader?dc=dc-1 |
| kvs | /dc-1/kv | /v1/kv/?keys&dc=dc-1&separator=%2F |
| acls | /dc-1/acls/tokens | /v1/acl/tokens?dc=dc-1 |
| intentions | /dc-1/intentions | /v1/connect/intentions?dc=dc-1 |

View File

@ -0,0 +1,10 @@
import steps from '../../steps';
// step definitions that are shared between features should be moved to the
// tests/acceptance/steps/steps.js file
export default function(assert) {
return steps(assert).then('I should find a file', function() {
assert.ok(true, this.step);
});
}

View File

@ -1,10 +1,14 @@
export default function(visitable, clickable, attribute, collection, filter) {
const node = {
name: attribute('data-test-node'),
leader: attribute('data-test-leader', '[data-test-leader]'),
node: clickable('header a'),
};
return {
visit: visitable('/:dc/nodes'),
nodes: collection('[data-test-node]', {
name: attribute('data-test-node'),
node: clickable('header a'),
}),
nodes: collection('[data-test-node]', node),
healthyNodes: collection('.healthy [data-test-node]', node),
unHealthyNodes: collection('.unhealthy [data-test-node]', node),
filter: filter,
};
}

View File

@ -880,9 +880,9 @@
js-yaml "^3.13.1"
"@hashicorp/consul-api-double@^2.0.1":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-2.3.0.tgz#1163f6dacb29d43d8dac4d1473263c257321682a"
integrity sha512-wbaOyOoA1X5Ur7Gj4VSZkor1zuJ2+GTbavPJGtpZZXd6CtL3RXC4HaldruBIF79j3lBXVgS/Y9ETMfGLdoAYgA==
version "2.5.0"
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-2.5.0.tgz#d9540a38ee652d55ed90956850c9e5cbcde89454"
integrity sha512-RZcVIPQ4M4TZzFe2mWm7M5w28yOIpVgiYZI5ax+JG0Yr5TVbhJPMxhdb1es73cILuqIi9Fr+73OJ5IAospgPBw==
"@hashicorp/ember-cli-api-double@^2.0.0":
version "2.0.0"