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:
parent
586ae3d5cb
commit
454ce7166b
|
@ -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,
|
||||
}}
|
||||
`;
|
||||
},
|
||||
});
|
|
@ -20,19 +20,9 @@
|
|||
</dd>
|
||||
</dl>
|
||||
{{#if (gt item.InstanceCount 0)}}
|
||||
{{#if (eq item.Kind 'terminating-gateway')}}
|
||||
<a data-test-service-name href={{href-to "dc.services.show.services" item.Name}}>
|
||||
{{item.Name}}
|
||||
</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}}
|
||||
<a data-test-service-name href={{href-to "dc.services.show.index" item.Name}}>
|
||||
{{item.Name}}
|
||||
</a>
|
||||
{{else}}
|
||||
<p data-test-service-name>
|
||||
{{item.Name}}
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
@import './skin';
|
||||
@import './layout';
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -18,13 +18,18 @@ export default Controller.extend({
|
|||
action: 'update',
|
||||
});
|
||||
}
|
||||
[e.target, this.intentions, this.chain, this.proxies, this.gatewayServices].forEach(
|
||||
function(item) {
|
||||
if (item && typeof item.close === 'function') {
|
||||
item.close();
|
||||
}
|
||||
[
|
||||
e.target,
|
||||
this.intentions,
|
||||
this.chain,
|
||||
this.proxies,
|
||||
this.gatewayServices,
|
||||
this.topology,
|
||||
].forEach(function(item) {
|
||||
if (item && typeof item.close === 'function') {
|
||||
item.close();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
});
|
|
@ -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;
|
||||
}),
|
||||
});
|
|
@ -25,6 +25,9 @@ export const routes = {
|
|||
_options: { path: '/create' },
|
||||
},
|
||||
},
|
||||
topology: {
|
||||
_options: { path: '/topology' },
|
||||
},
|
||||
services: {
|
||||
_options: { path: '/services' },
|
||||
},
|
||||
|
|
|
@ -19,19 +19,33 @@ export default Route.extend({
|
|||
urls: this.settings.findBySlug('urls'),
|
||||
chain: null,
|
||||
proxies: [],
|
||||
}).then(model => {
|
||||
return ['connect-proxy', 'mesh-gateway', 'ingress-gateway', 'terminating-gateway'].includes(
|
||||
get(model, 'items.firstObject.Service.Kind')
|
||||
)
|
||||
? model
|
||||
: hash({
|
||||
...model,
|
||||
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}`
|
||||
),
|
||||
});
|
||||
});
|
||||
topology: null,
|
||||
})
|
||||
.then(model => {
|
||||
return ['connect-proxy', 'mesh-gateway', 'ingress-gateway', 'terminating-gateway'].includes(
|
||||
get(model, 'items.firstObject.Service.Kind')
|
||||
)
|
||||
? model
|
||||
: hash({
|
||||
...model,
|
||||
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) {
|
||||
this._super(...arguments);
|
||||
|
|
|
@ -1,6 +1,37 @@
|
|||
import Route from 'consul-ui/routing/route';
|
||||
import to from 'consul-ui/utils/routing/redirect-to';
|
||||
import Route from '@ember/routing/route';
|
||||
import { get } from '@ember/object';
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
});
|
|
@ -13,6 +13,7 @@ export default Service.extend({
|
|||
proxies: service('repository/proxy'),
|
||||
['proxy-instance']: service('repository/proxy'),
|
||||
['discovery-chain']: service('repository/discovery-chain'),
|
||||
['topology']: service('repository/topology'),
|
||||
coordinates: service('repository/coordinate'),
|
||||
sessions: service('repository/session'),
|
||||
namespaces: service('repository/nspace'),
|
||||
|
@ -100,6 +101,14 @@ export default Service.extend({
|
|||
break;
|
||||
}
|
||||
break;
|
||||
case 'topology':
|
||||
[method, slug] = rest;
|
||||
switch (method) {
|
||||
case 'for-service':
|
||||
find = configuration => repo.findBySlug(slug, dc, nspace, configuration);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'sessions':
|
||||
[method, ...slug] = rest;
|
||||
switch (method) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
|
@ -67,3 +67,4 @@
|
|||
@import 'consul-ui/components/consul-intention-permission-form';
|
||||
@import 'consul-ui/components/consul-intention-permission-header-list';
|
||||
@import 'consul-ui/components/role-selector';
|
||||
@import 'consul-ui/components/topology-metrics';
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
<EventSource @src={{chain}} />
|
||||
<EventSource @src={{intentions}} />
|
||||
<EventSource @src={{proxies}} />
|
||||
<EventSource @src={{gatewayServices}} />
|
||||
<EventSource @src={{topology}} />
|
||||
{{title item.Service.Service}}
|
||||
<AppView>
|
||||
<BlockSlot @name="notification" as |status type|>
|
||||
|
@ -24,21 +26,24 @@
|
|||
<TabNav @items={{
|
||||
compact
|
||||
(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')
|
||||
(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')
|
||||
(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')
|
||||
(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
|
||||
(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)
|
||||
(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"))
|
||||
'')
|
||||
)
|
||||
}}/>
|
||||
|
|
|
@ -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>
|
|
@ -53,9 +53,10 @@
|
|||
"@babel/plugin-proposal-class-properties": "^7.10.1",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.5.5",
|
||||
"@ember/optional-features": "^1.3.0",
|
||||
"@ember/render-modifiers": "^1.0.2",
|
||||
"@glimmer/component": "^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",
|
||||
"@xstate/fsm": "^1.4.0",
|
||||
"babel-eslint": "^10.0.3",
|
||||
|
|
|
@ -34,6 +34,7 @@ Feature: dc / list-blocking
|
|||
dc: dc-1
|
||||
service: service
|
||||
---
|
||||
And I click instances on the tabs
|
||||
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
|
||||
|
|
|
@ -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 I see 1 service model
|
||||
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
|
||||
|
||||
|
|
|
@ -94,6 +94,7 @@ Feature: dc / services / show: Show Service
|
|||
dc: dc1
|
||||
service: service-0
|
||||
---
|
||||
And I click instances on the tabs
|
||||
Then I see address on the instances like yaml
|
||||
---
|
||||
- "1.1.1.1:8080"
|
||||
|
|
|
@ -19,9 +19,8 @@ Feature: dc / services / show / dc-switch : Switching Datacenters
|
|||
dc: dc-1
|
||||
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
|
||||
And I click dcs.1.name on the navigation
|
||||
Then the url should be /dc-2/services/consul/instances
|
||||
And I see instancesUrl on the tabs contains "/dc-2/services/consul/instances"
|
||||
Then the url should be /dc-2/services/consul/topology
|
||||
|
|
|
@ -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
|
|
@ -41,7 +41,7 @@ Feature: page-navigation
|
|||
Where:
|
||||
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
| 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 |
|
||||
# | acl | acls | /dc-1/acls/anonymous | /v1/acl/info/anonymous?dc=dc-1 | /dc-1/acls |
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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(), {});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -1527,10 +1527,10 @@
|
|||
faker "^4.1.0"
|
||||
js-yaml "^3.13.1"
|
||||
|
||||
"@hashicorp/consul-api-double@^5.0.0":
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-5.2.1.tgz#a411139fa1afa0dfaf1b9973f21275530a39939b"
|
||||
integrity sha512-ASQv2I8iprnFmpAvbHEoKE8MXTpOxdeBan6nkgobmz4OyvMcqu/h29CGEXZ9j63NX6+nxmE84nV5yAqADRubGQ==
|
||||
"@hashicorp/consul-api-double@^5.2.3":
|
||||
version "5.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/consul-api-double/-/consul-api-double-5.2.3.tgz#c34cec063b519595c49bb3fce799541f7d967f66"
|
||||
integrity sha512-NlnBUHoXLlQwTB1lFzYvaIUZnf5KOGnohXRm4D3B8xVC+D0py6dTP5dj3NpBuxrG5b0xSv2zTF3tz9Y5nehOzQ==
|
||||
|
||||
"@hashicorp/ember-cli-api-double@^3.1.0":
|
||||
version "3.1.2"
|
||||
|
|
Loading…
Reference in New Issue