diff --git a/ui-v2/app/adapters/node.js b/ui-v2/app/adapters/node.js index 3b1cace76b5f..7e7ba7514a5c 100644 --- a/ui-v2/app/adapters/node.js +++ b/ui-v2/app/adapters/node.js @@ -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; diff --git a/ui-v2/app/routes/dc/nodes/index.js b/ui-v2/app/routes/dc/nodes/index.js index 8cbd56ecdc04..2852d3dff642 100644 --- a/ui-v2/app/routes/dc/nodes/index.js +++ b/ui-v2/app/routes/dc/nodes/index.js @@ -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) { diff --git a/ui-v2/app/services/repository/node.js b/ui-v2/app/services/repository/node.js index eec4f211d3eb..70ee56e30842 100644 --- a/ui-v2/app/services/repository/node.js +++ b/ui-v2/app/services/repository/node.js @@ -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); + }, }); diff --git a/ui-v2/app/services/repository/type/event-source.js b/ui-v2/app/services/repository/type/event-source.js index 31c1b41ea9b5..3999abf7a317 100644 --- a/ui-v2/app/services/repository/type/event-source.js +++ b/ui-v2/app/services/repository/type/event-source.js @@ -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) { diff --git a/ui-v2/app/services/store.js b/ui-v2/app/services/store.js index 640e1b7d1376..081508e9a3ed 100644 --- a/ui-v2/app/services/store.js +++ b/ui-v2/app/services/store.js @@ -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); + }, }); diff --git a/ui-v2/app/styles/components/healthchecked-resource.scss b/ui-v2/app/styles/components/healthchecked-resource.scss index 88937a038606..80fa42d228ea 100644 --- a/ui-v2/app/styles/components/healthchecked-resource.scss +++ b/ui-v2/app/styles/components/healthchecked-resource.scss @@ -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; diff --git a/ui-v2/app/templates/components/healthchecked-resource.hbs b/ui-v2/app/templates/components/healthchecked-resource.hbs index b3fc52b3f2fb..465f26eb0fe5 100644 --- a/ui-v2/app/templates/components/healthchecked-resource.hbs +++ b/ui-v2/app/templates/components/healthchecked-resource.hbs @@ -1,5 +1,5 @@ {{#stats-card}} - {{#block-slot 'icon'}}{{#if false}}Leader{{/if}}{{/block-slot}} + {{#block-slot 'icon'}}{{yield}}{{/block-slot}} {{#block-slot 'mini-stat'}} {{#if (eq checks.length 0)}} {{checks.length}} diff --git a/ui-v2/app/templates/dc/nodes/index.hbs b/ui-v2/app/templates/dc/nodes/index.hbs index 0e46adfccfc8..761cc4830fd3 100644 --- a/ui-v2/app/templates/dc/nodes/index.hbs +++ b/ui-v2/app/templates/dc/nodes/index.hbs @@ -1,72 +1,84 @@ {{#app-view class="node list"}} - {{#block-slot 'header'}} -

- Nodes {{format-number items.length}} total -

- - {{/block-slot}} - {{#block-slot 'toolbar'}} + {{#block-slot 'header'}} +

+ Nodes {{format-number items.length}} total +

+ + {{/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) }} -
-

Unhealthy Nodes

-
- {{! think about 2 differing views here }} -
+
+{{/if}} +{{#if (gt healthy.length 0) }} +
+

Healthy Nodes

+ {{#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)}} + Leader + {{/if}} + {{/block-slot}} + {{/healthchecked-resource}} + {{/list-collection}} + {{/block-slot}} + {{#block-slot 'empty'}} +

+ There are no healthy nodes for that search. +

+ {{/block-slot}} + {{/changeable-set}} +
{{/if}} {{#if (and (eq healthy.length 0) (eq unhealthy.length 0)) }} -

- There are no nodes. -

+

+ There are no nodes. +

{{/if}} - {{/block-slot}} + {{/block-slot}} {{/app-view}} \ No newline at end of file diff --git a/ui-v2/package.json b/ui-v2/package.json index eb67171b05d2..beddf8b88aca 100644 --- a/ui-v2/package.json +++ b/ui-v2/package.json @@ -52,6 +52,7 @@ "base64-js": "^1.3.0", "broccoli-asset-rev": "^2.4.5", "chalk": "^2.4.2", + "clipboard": "^2.0.4", "dart-sass": "^1.14.1", "ember-ajax": "^3.0.0", "ember-auto-import": "^1.4.0", @@ -103,8 +104,7 @@ "node-sass": "^4.9.3", "prettier": "^1.10.2", "svgo": "^1.0.5", - "text-encoding": "^0.6.4", - "clipboard": "^2.0.4" + "text-encoding": "^0.6.4" }, "engines": { "node": "^4.5 || 6.* || >= 7.*" diff --git a/ui-v2/tests/acceptance/dc/nodes/index.feature b/ui-v2/tests/acceptance/dc/nodes/index.feature index 4e6c4d063772..9eb963024d71 100644 --- a/ui-v2/tests/acceptance/dc/nodes/index.feature +++ b/ui-v2/tests/acceptance/dc/nodes/index.feature @@ -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 diff --git a/ui-v2/tests/acceptance/dc/nodes/no-leader.feature b/ui-v2/tests/acceptance/dc/nodes/no-leader.feature new file mode 100644 index 000000000000..84f6036cfbed --- /dev/null +++ b/ui-v2/tests/acceptance/dc/nodes/no-leader.feature @@ -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 + diff --git a/ui-v2/tests/acceptance/page-navigation.feature b/ui-v2/tests/acceptance/page-navigation.feature index 1a133322235f..1fd782123165 100644 --- a/ui-v2/tests/acceptance/page-navigation.feature +++ b/ui-v2/tests/acceptance/page-navigation.feature @@ -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 | diff --git a/ui-v2/tests/acceptance/steps/dc/nodes/no-leader-steps.js b/ui-v2/tests/acceptance/steps/dc/nodes/no-leader-steps.js new file mode 100644 index 000000000000..a7eff3228bf4 --- /dev/null +++ b/ui-v2/tests/acceptance/steps/dc/nodes/no-leader-steps.js @@ -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); + }); +} diff --git a/ui-v2/tests/pages/dc/nodes/index.js b/ui-v2/tests/pages/dc/nodes/index.js index 1899e7df5ecd..25045ec38850 100644 --- a/ui-v2/tests/pages/dc/nodes/index.js +++ b/ui-v2/tests/pages/dc/nodes/index.js @@ -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, }; } diff --git a/ui-v2/yarn.lock b/ui-v2/yarn.lock index 74144f5ee3cf..2dc908027408 100644 --- a/ui-v2/yarn.lock +++ b/ui-v2/yarn.lock @@ -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"