ui: Service Mesh - Topology tab and basic layout (#8788)

* Create Topology Tab with foundational layout and styling

* Create Toplogy Metrics component with dynamic SVG

* Add ember-render-modifiers addon

* Implement Topology Metrics comp and fix up styling

* Create topology endpoint with tests

* Move arrow drawing to index.js file

* Add topology to show controller

* Fix up conditional wrapper, tabs positioning, links, and styling

* Group upstreams by dc and fix up styling

* Create service/health-percentage helper

* Add health check percentages to upstreams and downstreams

* Basic Layout

* Upgrade @hashicorp/consul-api-double to v5.2.3

* Renamed endpoint to be service-topology

* Refactor styling

* Update to only show Topology tab when Connect is enabled

* Fix bug and changes from review notes

* Remove unused functions that are replaced with SVG markers

* Refactor to resuse svg-curve helper

* Use the render-template helper for the metrics link

* Add topology default null to services show route

* Removed unused ID

* Fix up tests broken by redirect to /topology
This commit is contained in:
Kenia 2020-10-05 13:07:35 -04:00 committed by GitHub
parent 586ae3d5cb
commit 454ce7166b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 845 additions and 72 deletions

View file

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

View file

@ -20,19 +20,9 @@
</dd> </dd>
</dl> </dl>
{{#if (gt item.InstanceCount 0)}} {{#if (gt item.InstanceCount 0)}}
{{#if (eq item.Kind 'terminating-gateway')}} <a data-test-service-name href={{href-to "dc.services.show.index" item.Name}}>
<a data-test-service-name href={{href-to "dc.services.show.services" item.Name}}> {{item.Name}}
{{item.Name}} </a>
</a>
{{else if (eq item.Kind 'ingress-gateway')}}
<a data-test-service-name href={{href-to "dc.services.show.upstreams" item.Name}}>
{{item.Name}}
</a>
{{else}}
<a data-test-service-name href={{href-to "dc.services.show.instances" item.Name}}>
{{item.Name}}
</a>
{{/if}}
{{else}} {{else}}
<p data-test-service-name> <p data-test-service-name>
{{item.Name}} {{item.Name}}

View file

@ -0,0 +1,162 @@
{{on-window 'resize' (action this.calculate)}}
<div {{did-insert (action this.calculate)}} {{did-update (action this.calculate) @upstreams @downstreams}} class="topology-container">
{{#if (gt @downstreams.length 0)}}
<div id="downstream-container">
<div>
<p>{{@dc}}</p>
<span>
<Tooltip>
Only showing downstreams within the current datacenter for {{@service.Service.Service}}.
</Tooltip>
</span>
</div>
{{#each @downstreams as |downstream|}}
<div class="card">
<p>
{{downstream.Name}}
</p>
<div class="detail">
{{#let (service/health-percentage downstream) as |percentage|}}
{{#if (not-eq percentage.passing 0)}}
<span class="passing">{{percentage.passing}}%</span>
{{/if}}
{{#if (not-eq percentage.warning 0)}}
<span class="warning">{{percentage.warning}}%</span>
{{/if}}
{{#if (not-eq percentage.critical 0)}}
<span class="critical">{{percentage.critical}}%</span>
{{/if}}
{{/let}}
</div>
</div>
{{/each}}
</div>
{{/if}}
<div id="metrics-container">
<div>
{{@service.Service.Service}}
</div>
<div>
{{#if @metricsHref}}
<a class="metrics-link" href={{@metricsHref}} target="_blank" rel="noopener noreferrer">Open metrics Dashboard</a>
{{else}}
<a class="settings-link" href={{href-to 'settings'}}>Configure metrics dashboard</a>
{{/if}}
</div>
</div>
<div id="downstream-lines">
{{#if (gt this.downLines.length 0)}}
<svg
viewBox={{concat downView.x ' ' downView.y ' ' downView.width ' ' downView.height}}
preserveAspectRatio="none"
>
<defs>
<marker id="dot" viewBox="-2 -2 15 15" refX="6" refY="6" markerWidth="6" markerHeight="6">
<circle
cx="6"
cy="6"
r="6"
/>
</marker>
<marker id="arrow" viewBox="-1 -1 12 12" refX="5" refY="5"
markerWidth="6" markerHeight="6"
orient="auto-start-reverse">
<polygon points="0 0 10 5 0 10" />
</marker>
</defs>
{{#each this.downLines as |svg| }}
<path
d={{svg-curve svg.dest src=svg.src}}
marker-start="url(#dot)"
marker-end="url(#arrow)"
/>
{{/each}}
</svg>
{{/if}}
</div>
{{#if (gt @upstreams.length 0)}}
<div id="upstream-column">
{{#each-in (group-by "Datacenter" @upstreams) as |dc upstreams|}}
<div id="upstream-container">
<p>{{dc}}</p>
{{#each upstreams as |upstream|}}
<div class="card">
<p>
{{upstream.Name}}
</p>
<div class="detail">
{{#if (and nspace (env 'CONSUL_NSPACES_ENABLED'))}}
<dl class="nspace">
<dt>
<Tooltip>
Namespace
</Tooltip>
</dt>
<dd>
{{upstream.Namespace}}
</dd>
</dl>
{{/if}}
{{#if (eq upstream.Datacenter @dc)}}
{{#let (service/health-percentage upstream) as |percentage|}}
{{#if (not-eq percentage.passing 0)}}
<span class="passing">{{percentage.passing}}%</span>
{{/if}}
{{#if (not-eq percentage.warning 0)}}
<span class="warning">{{percentage.warning}}%</span>
{{/if}}
{{#if (not-eq percentage.critical 0)}}
<span class="critical">{{percentage.critical}}%</span>
{{/if}}
{{/let}}
{{else}}
<dl class="health">
<dt>
<Tooltip>
We are unable to determine the health status of services in remote datacenters.
</Tooltip>
</dt>
<dd>
Health
</dd>
</dl>
{{/if}}
</div>
</div>
{{/each}}
</div>
{{/each-in}}
</div>
{{/if}}
<div id="upstream-lines">
{{#if (gt this.upLines.length 0)}}
<svg
viewBox={{concat this.centerDimensions.x ' ' upView.y ' ' upView.width ' ' upView.height}}
preserveAspectRatio="none"
>
<defs>
<marker id="dot" viewBox="-2 -2 15 15" refX="6" refY="6" markerWidth="6" markerHeight="6">
<circle
cx="6"
cy="6"
r="6"
/>
</marker>
<marker id="arrow" viewBox="-1 -1 12 12" refX="5" refY="5"
markerWidth="6" markerHeight="6"
orient="auto-start-reverse">
<polygon points="0 0 10 5 0 10" />
</marker>
</defs>
{{#each this.upLines as |svg| }}
<path
d={{svg-curve svg.dest src=svg.src}}
marker-start="url(#dot)"
marker-end="url(#arrow)"
/>
{{/each}}
</svg>
{{/if}}
</div>
</div>

View file

@ -0,0 +1,73 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class TopologyMetrics extends Component {
// =attributes
@tracked centerDimensions;
@tracked downView;
@tracked downLines = [];
@tracked upView;
@tracked upLines = [];
// =methods
drawDownLines(items) {
return items.map(item => {
const dimensions = item.getBoundingClientRect();
const dest = {
x: this.centerDimensions.x,
y: this.centerDimensions.y + this.centerDimensions.height / 4,
};
const src = {
x: dimensions.x + dimensions.width,
y: dimensions.y + dimensions.height / 2,
};
return {
dest: dest,
src: src,
};
});
}
drawUpLines(items) {
return items.map(item => {
const dimensions = item.getBoundingClientRect();
const dest = {
x: dimensions.x - dimensions.width - 26,
y: dimensions.y + dimensions.height / 2,
};
const src = {
x: this.centerDimensions.x + 20,
y: this.centerDimensions.y + this.centerDimensions.height / 4,
};
return {
dest: dest,
src: src,
};
});
}
// =actions
@action
calculate() {
// Calculate viewBox dimensions
this.downView = document.querySelector('#downstream-lines').getBoundingClientRect();
this.upView = document.querySelector('#upstream-lines').getBoundingClientRect();
// Get Card elements positions
const downCards = [...document.querySelectorAll('#downstream-container .card')];
const grafanaCard = document.querySelector('#metrics-container');
const upCards = [...document.querySelectorAll('#upstream-column .card')];
// Set center positioning points
this.centerDimensions = grafanaCard.getBoundingClientRect();
// Set Downstream Cards Positioning points
this.downLines = this.drawDownLines(downCards);
// Set Upstream Cards Positioning points
this.upLines = this.drawUpLines(upCards);
}
}

View file

@ -0,0 +1,2 @@
@import './skin';
@import './layout';

View file

@ -0,0 +1,98 @@
.topology-container {
display: grid;
height: 100%;
align-items: start;
grid-template-columns: 2fr 20px 1fr 20px 2fr 20px 1fr 20px 2fr;
grid-template-rows: 50px 1fr 50px;
}
// Grid Layout
#downstream-container {
grid-row: 1 / 3;
grid-column: 1 / 3;
}
#downstream-lines {
grid-row: 1 / 3;
grid-column: 2 / 5;
}
#upstream-lines {
grid-row: 1 / 3;
grid-column: 6 / 9;
}
#upstream-column {
grid-row: 1 / 3;
grid-column: 8 / 10;
}
// Columns/Containers & Lines
#downstream-lines,
#upstream-lines {
z-index: 1;
position: relative;
height: 100%;
}
#downstream-container,
#upstream-container {
padding: 12px;
}
#downstream-container div:first-child {
display: inline-flex;
span::before {
@extend %with-info-circle-outline-mask, %as-pseudo;
margin-left: 4px;
}
}
#upstream-column #upstream-container:not(:last-child) {
margin-bottom: 8px;
}
#upstream-container .card:not(:last-child),
#downstream-container .card:not(:last-child) {
margin-bottom: 8px;
}
#upstream-container .card,
#downstream-container .card {
padding: 12px;
p {
font-size: 16px;
font-weight: 600;
margin-bottom: 0 !important;
}
div {
display: inline-flex;
dl {
display: inline-flex;
margin-right: 8px;
}
span {
margin-right: 8px;
}
span::before,
dt::before {
margin-right: 4px;
}
.nspace dt::before,
.health dt::before {
margin-top: 2px;
}
}
}
// Metrics Container
#metrics-container {
grid-row: 2 / 3;
grid-column: 4 / 7;
div:first-child {
padding: 12px;
border: none;
font-size: 16px;
font-weight: bold;
}
div:nth-child(2) {
padding: 18px;
a::before {
margin-right: 4px;
}
}
}

View file

@ -0,0 +1,108 @@
.topology-container {
color: $gray-700;
}
// Columns/Containers & Lines
#downstream-container,
#metrics-container,
#upstream-container {
border: 1px solid $gray-200;
border-radius: $decor-radius-100;
}
#downstream-container,
#upstream-container {
background-color: $gray-100;
}
#downstream-container div:first-child {
display: inline-flex;
span::before {
@extend %with-info-circle-outline-mask, %as-pseudo;
background-color: $gray-500;
}
}
#upstream-container .card,
#downstream-container .card {
background-color: $white;
border-radius: $decor-radius-100;
border: 1px solid $gray-200;
div {
dd {
color: $gray-700;
}
.nspace dt::before {
@extend %with-folder-outline-mask, %as-pseudo;
}
.health dt::before {
@extend %with-help-circle-outline-mask, %as-pseudo;
}
.nspace dt::before {
@extend %with-folder-outline-mask, %as-pseudo;
}
.health dt::before {
@extend %with-help-circle-outline-mask, %as-pseudo;
}
.nspace dt::before,
.health dt::before {
background-color: $gray-500;
}
.passing::before {
@extend %with-check-circle-fill-color-mask, %as-pseudo;
background-color: $green-500;
}
.warning::before {
@extend %with-alert-triangle-color-mask, %as-pseudo;
background-color: $orange-500;
}
.critical::before {
@extend %with-cancel-square-fill-color-mask, %as-pseudo;
background-color: $red-500;
}
}
}
// Metrics Container
#metrics-container {
div:first-child {
background-color: $white;
}
div:nth-child(2) {
background-color: $gray-100;
a {
color: $gray-700;
}
a::before {
background-color: $gray-500;
}
a:hover {
color: $color-action;
}
.metrics-link::before {
@extend %with-exit-mask, %as-pseudo;
}
.settings-link::before {
@extend %with-docs-mask, %as-pseudo;
}
}
}
// SVG Line styling
#downstream-lines svg,
#upstream-lines svg {
path {
fill: $transparent;
}
circle {
fill: $white;
}
polygon {
fill: $gray-300;
stroke-linejoin: round;
}
path,
circle,
polygon {
stroke: $gray-300;
stroke-width: 2;
}
}

View file

@ -18,13 +18,18 @@ export default Controller.extend({
action: 'update', action: 'update',
}); });
} }
[e.target, this.intentions, this.chain, this.proxies, this.gatewayServices].forEach( [
function(item) { e.target,
if (item && typeof item.close === 'function') { this.intentions,
item.close(); this.chain,
} this.proxies,
this.gatewayServices,
this.topology,
].forEach(function(item) {
if (item && typeof item.close === 'function') {
item.close();
} }
); });
} }
}, },
}, },

View file

@ -0,0 +1,10 @@
import { helper } from '@ember/component/helper';
export default helper(function serviceHealthPercentage([params] /*, hash*/) {
const total = params.ChecksCritical + params.ChecksPassing + params.ChecksWarning;
return {
passing: Math.round((params.ChecksPassing / total) * 100),
warning: Math.round((params.ChecksWarning / total) * 100),
critical: Math.round((params.ChecksCritical / total) * 100),
};
});

View file

@ -0,0 +1,18 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { computed } from '@ember/object';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'ServiceName';
export default Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
Datacenter: attr('string'),
Namespace: attr('string'),
Upstreams: attr(),
Downstreams: attr(),
meta: attr(),
Exists: computed(function() {
return true;
}),
});

View file

@ -25,6 +25,9 @@ export const routes = {
_options: { path: '/create' }, _options: { path: '/create' },
}, },
}, },
topology: {
_options: { path: '/topology' },
},
services: { services: {
_options: { path: '/services' }, _options: { path: '/services' },
}, },

View file

@ -19,19 +19,33 @@ export default Route.extend({
urls: this.settings.findBySlug('urls'), urls: this.settings.findBySlug('urls'),
chain: null, chain: null,
proxies: [], proxies: [],
}).then(model => { topology: null,
return ['connect-proxy', 'mesh-gateway', 'ingress-gateway', 'terminating-gateway'].includes( })
get(model, 'items.firstObject.Service.Kind') .then(model => {
) return ['connect-proxy', 'mesh-gateway', 'ingress-gateway', 'terminating-gateway'].includes(
? model get(model, 'items.firstObject.Service.Kind')
: hash({ )
...model, ? model
chain: this.data.source(uri => uri`/${nspace}/${dc}/discovery-chain/${params.name}`), : hash({
proxies: this.data.source( ...model,
uri => uri`/${nspace}/${dc}/proxies/for-service/${params.name}` 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 ['mesh-gateway', 'terminating-gateway'].includes(
get(model, 'items.firstObject.Service.Kind')
)
? model
: hash({
...model,
topology: this.data.source(
uri => uri`/${nspace}/${dc}/topology/for-service/${params.name}`
),
});
});
}, },
setupController: function(controller, model) { setupController: function(controller, model) {
this._super(...arguments); this._super(...arguments);

View file

@ -1,6 +1,37 @@
import Route from 'consul-ui/routing/route'; import Route from '@ember/routing/route';
import to from 'consul-ui/utils/routing/redirect-to'; import { get } from '@ember/object';
export default Route.extend({ export default Route.extend({
redirect: to('instances'), afterModel: function(model, transition) {
const parent = this.routeName
.split('.')
.slice(0, -1)
.join('.');
// the default selected tab depends on whether you have any healthchecks or not
// so check the length here.
let to = 'topology';
const parentModel = this.modelFor(parent);
const kind = get(parentModel, 'items.firstObject.Service.Kind');
switch (kind) {
case 'ingress-gateway':
if (!get(parentModel, 'topology.Exists')) {
to = 'upstreams';
}
break;
case 'terminating-gateway':
to = 'services';
break;
case 'mesh-gateway':
to = 'instances';
break;
default:
if (!get(parentModel, 'topology.Exists')) {
to = 'instances';
}
}
this.replaceWith(`${parent}.${to}`, parentModel);
},
}); });

View file

@ -0,0 +1,15 @@
import Route from '@ember/routing/route';
export default Route.extend({
model: function() {
const parent = this.routeName
.split('.')
.slice(0, -1)
.join('.');
return this.modelFor(parent);
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View file

@ -0,0 +1,17 @@
import Serializer from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/topology';
export default Serializer.extend({
primaryKey: PRIMARY_KEY,
slugKey: SLUG_KEY,
respondForQueryRecord: function(respond, query) {
return this._super(function(cb) {
return respond(function(headers, body) {
return cb(headers, {
...body,
[SLUG_KEY]: query.id,
});
});
}, query);
},
});

View file

@ -13,6 +13,7 @@ export default Service.extend({
proxies: service('repository/proxy'), proxies: service('repository/proxy'),
['proxy-instance']: service('repository/proxy'), ['proxy-instance']: service('repository/proxy'),
['discovery-chain']: service('repository/discovery-chain'), ['discovery-chain']: service('repository/discovery-chain'),
['topology']: service('repository/topology'),
coordinates: service('repository/coordinate'), coordinates: service('repository/coordinate'),
sessions: service('repository/session'), sessions: service('repository/session'),
namespaces: service('repository/nspace'), namespaces: service('repository/nspace'),
@ -100,6 +101,14 @@ export default Service.extend({
break; break;
} }
break; break;
case 'topology':
[method, slug] = rest;
switch (method) {
case 'for-service':
find = configuration => repo.findBySlug(slug, dc, nspace, configuration);
break;
}
break;
case 'sessions': case 'sessions':
[method, ...slug] = rest; [method, ...slug] = rest;
switch (method) { switch (method) {

View file

@ -0,0 +1,32 @@
import RepositoryService from 'consul-ui/services/repository';
import { inject as service } from '@ember/service';
import { get, set } from '@ember/object';
const modelName = 'topology';
const ERROR_MESH_DISABLED = 'Connect must be enabled in order to use this endpoint';
export default RepositoryService.extend({
dcs: service('repository/dc'),
getModelName: function() {
return modelName;
},
findBySlug: function(slug, dc, nspace, configuration = {}) {
const datacenter = this.dcs.peekOne(dc);
if (datacenter !== null && !get(datacenter, 'MeshEnabled')) {
return Promise.resolve();
}
return this._super(...arguments).catch(e => {
const code = get(e, 'errors.firstObject.status');
const body = get(e, 'errors.firstObject.detail').trim();
switch (code) {
case '500':
if (datacenter !== null && body.endsWith(ERROR_MESH_DISABLED)) {
set(datacenter, 'MeshEnabled', false);
}
return;
default:
throw e;
}
});
},
});

View file

@ -67,3 +67,4 @@
@import 'consul-ui/components/consul-intention-permission-form'; @import 'consul-ui/components/consul-intention-permission-form';
@import 'consul-ui/components/consul-intention-permission-header-list'; @import 'consul-ui/components/consul-intention-permission-header-list';
@import 'consul-ui/components/role-selector'; @import 'consul-ui/components/role-selector';
@import 'consul-ui/components/topology-metrics';

View file

@ -2,6 +2,8 @@
<EventSource @src={{chain}} /> <EventSource @src={{chain}} />
<EventSource @src={{intentions}} /> <EventSource @src={{intentions}} />
<EventSource @src={{proxies}} /> <EventSource @src={{proxies}} />
<EventSource @src={{gatewayServices}} />
<EventSource @src={{topology}} />
{{title item.Service.Service}} {{title item.Service.Service}}
<AppView> <AppView>
<BlockSlot @name="notification" as |status type|> <BlockSlot @name="notification" as |status type|>
@ -24,21 +26,24 @@
<TabNav @items={{ <TabNav @items={{
compact compact
(array (array
(if topology.Datacenter
(hash label="Topology" href=(href-to "dc.services.show.topology") selected=(is-href "dc.services.show.topology"))
'')
(if (eq item.Service.Kind 'terminating-gateway') (if (eq item.Service.Kind 'terminating-gateway')
(hash label="Linked Services" href=(href-to "dc.services.show.services") selected=(is-href "dc.services.show.services")) (hash label="Linked Services" href=(href-to "dc.services.show.services") selected=(is-href "dc.services.show.services"))
'') '')
(if (eq item.Service.Kind 'ingress-gateway') (if (eq item.Service.Kind 'ingress-gateway')
(hash label="Upstreams" href=(href-to "dc.services.show.upstreams") selected=(is-href "dc.services.show.upstreams")) (hash label="Upstreams" href=(href-to "dc.services.show.upstreams") selected=(is-href "dc.services.show.upstreams"))
'') '')
(hash label="Instances" href=(href-to "dc.services.show.instances") selected=(is-href "dc.services.show.instances")) (hash label="Instances" href=(href-to "dc.services.show.instances") selected=(is-href "dc.services.show.instances"))
(if (not-eq item.Service.Kind 'terminating-gateway') (if (not-eq item.Service.Kind 'terminating-gateway')
(hash label="Intentions" href=(href-to "dc.services.show.intentions") selected=(is-href "dc.services.show.intentions")) (hash label="Intentions" href=(href-to "dc.services.show.intentions") selected=(is-href "dc.services.show.intentions"))
'') '')
(if chain.Chain (if chain.Chain
(hash label="Routing" href=(href-to "dc.services.show.routing") selected=(is-href "dc.services.show.routing")) (hash label="Routing" href=(href-to "dc.services.show.routing") selected=(is-href "dc.services.show.routing"))
'') '')
(if (not item.Service.Kind) (if (not item.Service.Kind)
(hash label="Tags" href=(href-to "dc.services.show.tags") selected=(is-href "dc.services.show.tags")) (hash label="Tags" href=(href-to "dc.services.show.tags") selected=(is-href "dc.services.show.tags"))
'') '')
) )
}}/> }}/>

View file

@ -0,0 +1,34 @@
<div id="tags" class="tab-section">
<div role="tabpanel">
{{#if topology}}
<TopologyMetrics
@service={{items.firstObject}}
@upstreams={{topology.Upstreams}}
@downstreams={{filter-by 'Datacenter' topology.Datacenter topology.Downstreams}}
@dc={{topology.Datacenter}}
@metricsHref={{render-template urls.service (hash
Datacenter=dc
Service=(hash Name=item.Service.Service)
)}}
/>
{{else}}
<EmptyState>
<BlockSlot @name="header">
<h2>
No dependencies
</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
This service has neither downstreams nor upstreams, which means that no services are configured to connect with it. Add upstreams and intentions to ensure this service is connected with the rest of your service mesh.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/connect/registration/service-registration#upstream-configuration-reference" rel="noopener noreferrer" target="_blank">Documentation on upstreams</a>
</li>
</BlockSlot>
</EmptyState>
{{/if}}
</div>
</div>

View file

@ -53,9 +53,10 @@
"@babel/plugin-proposal-class-properties": "^7.10.1", "@babel/plugin-proposal-class-properties": "^7.10.1",
"@babel/plugin-proposal-object-rest-spread": "^7.5.5", "@babel/plugin-proposal-object-rest-spread": "^7.5.5",
"@ember/optional-features": "^1.3.0", "@ember/optional-features": "^1.3.0",
"@ember/render-modifiers": "^1.0.2",
"@glimmer/component": "^1.0.0", "@glimmer/component": "^1.0.0",
"@glimmer/tracking": "^1.0.0", "@glimmer/tracking": "^1.0.0",
"@hashicorp/consul-api-double": "^5.0.0", "@hashicorp/consul-api-double": "^5.2.3",
"@hashicorp/ember-cli-api-double": "^3.1.0", "@hashicorp/ember-cli-api-double": "^3.1.0",
"@xstate/fsm": "^1.4.0", "@xstate/fsm": "^1.4.0",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",

View file

@ -34,6 +34,7 @@ Feature: dc / list-blocking
dc: dc-1 dc: dc-1
service: service service: service
--- ---
And I click instances on the tabs
Then the url should be /dc-1/[Url] Then the url should be /dc-1/[Url]
And pause until I see 3 [Model] models And pause until I see 3 [Model] models
And an external edit results in 5 [Model] models And an external edit results in 5 [Model] models

View file

@ -17,5 +17,5 @@ Feature: dc / services / show-with-slashes: Show Service that has slashes in its
Then the url should be /dc1/services Then the url should be /dc1/services
Then I see 1 service model Then I see 1 service model
And I click service on the services And I click service on the services
Then the url should be /dc1/services/hashicorp%2Fservice%2Fservice-0/instances Then the url should be /dc1/services/hashicorp%2Fservice%2Fservice-0/topology

View file

@ -94,6 +94,7 @@ Feature: dc / services / show: Show Service
dc: dc1 dc: dc1
service: service-0 service: service-0
--- ---
And I click instances on the tabs
Then I see address on the instances like yaml Then I see address on the instances like yaml
--- ---
- "1.1.1.1:8080" - "1.1.1.1:8080"

View file

@ -19,9 +19,8 @@ Feature: dc / services / show / dc-switch : Switching Datacenters
dc: dc-1 dc: dc-1
service: consul service: consul
--- ---
Then the url should be /dc-1/services/consul/instances
And I see instancesUrl on the tabs contains "/dc-1/services/consul/instances" Then the url should be /dc-1/services/consul/topology
When I click dc on the navigation When I click dc on the navigation
And I click dcs.1.name on the navigation And I click dcs.1.name on the navigation
Then the url should be /dc-2/services/consul/instances Then the url should be /dc-2/services/consul/topology
And I see instancesUrl on the tabs contains "/dc-2/services/consul/instances"

View file

@ -1,20 +0,0 @@
@setupApplicationTest
Feature: dc / services / intentions-error: An error with intentions doesn't 500 the page
Scenario:
Given 1 datacenter model with the value "dc1"
And 1 node model
And 1 service model from yaml
---
- Service:
Kind: ~
Name: service-0
ID: service-0-with-id
---
And the url "/v1/connect/intentions" responds with a 500 status
When I visit the service page for yaml
---
dc: dc1
service: service-0
---
And the title should be "service-0 - Consul"
And I see 1 instance model

View file

@ -41,7 +41,7 @@ Feature: page-navigation
Where: Where:
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Item | Model | URL | Endpoint | Back | | Item | Model | URL | Endpoint | Back |
| service | services | /dc-1/services/service-0/instances | /v1/discovery-chain/service-0?dc=dc-1&ns=@namespace | /dc-1/services | | service | services | /dc-1/services/service-0/topology | /v1/discovery-chain/service-0?dc=dc-1&ns=@namespace | /dc-1/services |
| kv | kvs | /dc-1/kv/0-key-value/edit | /v1/session/info/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=dc-1&ns=@namespace | /dc-1/kv | | kv | kvs | /dc-1/kv/0-key-value/edit | /v1/session/info/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=dc-1&ns=@namespace | /dc-1/kv |
# | acl | acls | /dc-1/acls/anonymous | /v1/acl/info/anonymous?dc=dc-1 | /dc-1/acls | # | acl | acls | /dc-1/acls/anonymous | /v1/acl/info/anonymous?dc=dc-1 | /dc-1/acls |
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

View file

@ -0,0 +1,50 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import getNspaceRunner from 'consul-ui/tests/helpers/get-nspace-runner';
const nspaceRunner = getNspaceRunner('topology');
module('Integration | Adapter | topology', function(hooks) {
setupTest(hooks);
const dc = 'dc-1';
const id = 'slug';
test('requestForQueryRecord returns the correct url/method', function(assert) {
const adapter = this.owner.lookup('adapter:topology');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/internal/ui/service-topology/${id}?dc=${dc}`;
const actual = adapter.requestForQueryRecord(client.requestParams.bind(client), {
dc: dc,
id: id,
});
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:topology');
const client = this.owner.lookup('service:client/http');
assert.throws(function() {
adapter.requestForQueryRecord(client.url, {
dc: dc,
});
});
});
test('requestForQueryRecord returns the correct body', function(assert) {
return nspaceRunner(
(adapter, serializer, client) => {
return adapter.requestForQueryRecord(client.body, {
id: id,
dc: dc,
ns: 'team-1',
index: 1,
});
},
{
index: 1,
ns: 'team-1',
},
{
index: 1,
},
this,
assert
);
});
});

View file

@ -0,0 +1,17 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | service/health-percentage', function(hooks) {
setupRenderingTest(hooks);
// Replace this with your real tests.
test('it renders', async function(assert) {
this.set('inputValue', {});
await render(hbs`{{service/health-percentage inputValue}}`);
assert.equal(this.element.textContent.trim(), {});
});
});

View file

@ -0,0 +1,37 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { get } from 'consul-ui/tests/helpers/api';
import { HEADERS_SYMBOL as META } from 'consul-ui/utils/http/consul';
module('Integration | Serializer | topology', function(hooks) {
setupTest(hooks);
test('respondForQueryRecord returns the correct data for item endpoint', function(assert) {
const serializer = this.owner.lookup('serializer:topology');
const dc = 'dc-1';
const id = 'slug';
const request = {
url: `/v1/internal/ui/service-topology/${id}?dc=${dc}`,
};
return get(request.url).then(function(payload) {
const expected = {
Datacenter: dc,
[META]: {},
uid: `["default","${dc}","${id}"]`,
};
const actual = serializer.respondForQueryRecord(
function(cb) {
const headers = {};
const body = payload;
return cb(headers, body);
},
{
dc: dc,
id: id,
}
);
assert.equal(actual.Datacenter, expected.Datacenter);
assert.equal(actual.uid, expected.uid);
});
});
});

View file

@ -0,0 +1,42 @@
import { moduleFor, test } from 'ember-qunit';
import repo from 'consul-ui/tests/helpers/repo';
moduleFor('service:repository/topology', 'Integration | Repository | topology', {
// Specify the other units that are required for this test.
integration: true,
});
const dc = 'dc-1';
const id = 'slug';
test('findBySlug returns the correct data for item endpoint', function(assert) {
return repo(
'Service',
'findBySlug',
this.subject(),
function retrieveStub(stub) {
return stub(`/v1/internal/ui/service-topology/${id}?dc=${dc}`, {
CONSUL_DISCOVERY_CHAIN_COUNT: 1,
});
},
function performTest(service) {
return service.findBySlug(id, dc);
},
function performAssertion(actual, expected) {
const result = expected(function(payload) {
return Object.assign(
{},
{
Datacenter: dc,
uid: `["default","${dc}","${id}"]`,
meta: {
cacheControl: undefined,
cursor: undefined,
},
},
payload
);
});
assert.equal(actual.Datacenter, result.Datacenter);
assert.equal(actual.uid, result.uid);
}
);
});

View file

@ -1527,10 +1527,10 @@
faker "^4.1.0" faker "^4.1.0"
js-yaml "^3.13.1" js-yaml "^3.13.1"
"@hashicorp/consul-api-double@^5.0.0": "@hashicorp/consul-api-double@^5.2.3":
version "5.2.1" version "5.2.3"
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-5.2.1.tgz#a411139fa1afa0dfaf1b9973f21275530a39939b" resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-5.2.3.tgz#c34cec063b519595c49bb3fce799541f7d967f66"
integrity sha512-ASQv2I8iprnFmpAvbHEoKE8MXTpOxdeBan6nkgobmz4OyvMcqu/h29CGEXZ9j63NX6+nxmE84nV5yAqADRubGQ== integrity sha512-NlnBUHoXLlQwTB1lFzYvaIUZnf5KOGnohXRm4D3B8xVC+D0py6dTP5dj3NpBuxrG5b0xSv2zTF3tz9Y5nehOzQ==
"@hashicorp/ember-cli-api-double@^3.1.0": "@hashicorp/ember-cli-api-double@^3.1.0":
version "3.1.2" version "3.1.2"