ui: Topology Intentions Popovers (#9137)

* Refactor grid styling for Topology page

* Crate TopologyMetrics Button component and move styling

* Create intention ID

* fixup button styling

* Return a link to the create intention page

* Rename Button to Popover component

* Fixup serializer test

* ui: Inline Topology Intention Actions  (#9153)

* Add arrow and dot to/from metrics back in

* Add addional space to have metrics wrap and show in smaller screens

* Move logic for finding positioning

* Use color variables

Co-authored-by: John Cowen <johncowen@users.noreply.github.com>
This commit is contained in:
Kenia 2020-11-12 10:40:15 -05:00 committed by John Cowen
parent bc77d91587
commit 676a520ce3
21 changed files with 306 additions and 92 deletions

View File

@ -1,11 +1,10 @@
{{#each @items as |item|}}
<a class="card"
href={{href-to "dc.services.show.index" item.Name}}
data-permission={{service/intention-permissions item}}
id="{{item.Namespace}}{{item.Name}}"
href={{href-to "dc.services.show.index" @item.Name}}
data-permission={{service/intention-permissions @item}}
id="{{@item.Namespace}}{{@item.Name}}"
>
<p>
{{item.Name}}
{{@item.Name}}
</p>
<div class="details">
{{#if (and (and nspace (env 'CONSUL_NSPACES_ENABLED')) @type)}}
@ -16,12 +15,12 @@
</Tooltip>
</dt>
<dd>
{{item.Namespace}}
{{@item.Namespace}}
</dd>
</dl>
{{/if}}
{{#if (eq item.Datacenter @dc)}}
{{#let (service/health-percentage item) as |percentage|}}
{{#if (eq @item.Datacenter @dc)}}
{{#let (service/health-percentage @item) as |percentage|}}
{{#if (not-eq percentage.passing 0)}}
<span class="passing">{{percentage.passing}}%</span>
{{/if}}
@ -49,18 +48,17 @@
{{#if (eq @type 'upstream')}}
<TopologyMetrics::Stats
@endpoint='upstream-summary-for-service'
@service={{@service}}
@item={{item.Name}}
@service={{@service.Service}}
@item={{@item.Name}}
@noMetricsReason={{@noMetricsReason}}
/>
{{else}}
<TopologyMetrics::Stats
@endpoint='downstream-summary-for-service'
@service={{@service}}
@item={{item.Name}}
@service={{@service.Service}}
@item={{@item.Name}}
@noMetricsReason={{@noMetricsReason}}
/>
{{/if}}
{{/if}}
</a>
{{/each}}
</a>

View File

@ -55,8 +55,11 @@
</svg>
{{/if}}
<TopologyMetrics::Icon
@positions={{this.iconPositions}}
@items={{@items}}
/>
{{#each @items as |item|}}
<TopologyMetrics::Popover
@position={{find-by 'id' (concat item.Namespace item.Name) this.iconPositions}}
@item={{item}}
@oncreate={{action @oncreate item @service}}
/>
{{/each}}

View File

@ -1,17 +0,0 @@
{{#each @items as |item|}}
{{#let (find-by 'id' (concat item.Namespace item.Name) @positions) as |style|}}
{{#if (and (not item.Intention.Allowed) (not item.Intention.HasPermissions))}}
<span class="deny" style={{{ concat 'top:' style.y 'px;left:' style.x 'px;'}}}>
<Tooltip>
An intention is set to 'deny' that prohibits these services from connecting.
</Tooltip>
</span>
{{else if item.Intention.HasPermissions}}
<span class="L7" style={{{ concat 'top:' style.y 'px;left:' style.x 'px;'}}}>
<Tooltip>
The intention between these services has Layer 7 permissions, so certain requests may or may not be permitted.
</Tooltip>
</span>
{{/if}}
{{/let}}
{{/each}}

View File

@ -1,6 +1,10 @@
{{on-window 'resize' (action this.calculate)}}
<div {{did-insert (action this.calculate)}} {{did-update (action this.calculate) @topology}} class="topology-container">
<div
{{did-insert (action this.calculate)}}
{{did-update (action this.calculate) @topology.Upstreams @topology.Downstreams}}
class="topology-container"
>
{{#if (gt @topology.Downstreams.length 0)}}
<div id="downstream-container">
<div>
@ -11,13 +15,15 @@
</Tooltip>
</span>
</div>
{{#each @topology.Downstreams as |item|}}
<TopologyMetrics::Card
@items={{@topology.Downstreams}}
@service={{@service.Service.Service}}
@item={{item}}
@service={{@service.Service}}
@dc={{@topology.Datacenter}}
@hasMetricsProvider={{this.hasMetricsProvider}}
@noMetricsReason={{this.noMetricsReason}}
/>
{{/each}}
</div>
{{/if}}
<div id="metrics-container">
@ -50,25 +56,29 @@
<div id="downstream-lines">
<TopologyMetrics::DownLines
@type='downstream'
@service={{@service}}
@view={{this.downView}}
@center={{this.centerDimensions}}
@lines={{this.downLines}}
@items={{@topology.Downstreams}}
/>
@oncreate={{action @oncreate}}
/>
</div>
{{#if (gt @topology.Upstreams.length 0)}}
<div id="upstream-column">
{{#each-in (group-by "Datacenter" @topology.Upstreams) as |dc upstreams|}}
<div id="upstream-container">
<p>{{dc}}</p>
{{#each upstreams as |item|}}
<TopologyMetrics::Card
@items={{upstreams}}
@service={{@service.Service.Service}}
@item={{item}}
@service={{@service.Service}}
@dc={{@topology.Datacenter}}
@type='upstream'
@hasMetricsProvider={{this.hasMetricsProvider}}
@noMetricsReason={{this.noMetricsReason}}
/>
{{/each}}
</div>
{{/each-in}}
</div>
@ -76,10 +86,12 @@
<div id="upstream-lines">
<TopologyMetrics::UpLines
@type='upstream'
@service={{@service}}
@view={{this.upView}}
@center={{this.centerDimensions}}
@lines={{this.upLines}}
@items={{@topology.Upstreams}}
/>
@oncreate={{action @oncreate}}
/>
</div>
</div>

View File

@ -35,7 +35,7 @@ export default class TopologyMetrics extends Component {
drawDownLines(items) {
const order = ['allow', 'deny'];
const dest = {
x: this.centerDimensions.x,
x: this.centerDimensions.x - 7,
y: this.centerDimensions.y + this.centerDimensions.height / 2,
};
@ -62,7 +62,7 @@ export default class TopologyMetrics extends Component {
drawUpLines(items) {
const order = ['allow', 'deny'];
const src = {
x: this.centerDimensions.x + 20,
x: this.centerDimensions.x + 5.5,
y: this.centerDimensions.y + this.centerDimensions.height / 2,
};
@ -70,7 +70,7 @@ export default class TopologyMetrics extends Component {
.map(item => {
const dimensions = item.getBoundingClientRect();
const dest = {
x: dimensions.x - dimensions.width - 26,
x: dimensions.x - dimensions.width - 25,
y: dimensions.y + dimensions.height / 2,
};

View File

@ -2,35 +2,40 @@
display: grid;
height: 100%;
align-items: start;
grid-template-columns: 2fr 20px 1fr 20px 2fr 20px 1fr 20px 2fr;
grid-template-columns: 2fr 1fr 2fr 1fr 2fr;
grid-template-rows: 50px 1fr 50px;
grid-template-areas:
"down-cards down-lines . up-lines up-cards"
"down-cards down-lines metrics up-lines up-cards"
"down-cards down-lines . up-lines up-cards";
}
// Grid Layout
#downstream-container {
grid-row: 1 / 3;
grid-column: 1 / 3;
grid-area: down-cards;
}
#downstream-lines {
grid-row: 1 / 3;
grid-column: 2 / 5;
grid-area: down-lines;
}
#upstream-lines {
grid-row: 1 / 3;
grid-column: 6 / 9;
grid-area: up-lines;
}
#upstream-column {
grid-row: 1 / 3;
grid-column: 8 / 10;
grid-area: up-cards;
}
// Columns/Containers & Lines
#downstream-lines,
#upstream-lines {
z-index: 1;
position: relative;
height: 100%;
}
#downstream-lines {
margin-left: -20px;
}
#upstream-lines {
margin-right: -20px;
}
#downstream-container,
#upstream-container {
padding: 12px;
@ -86,8 +91,7 @@
// Metrics Container
#metrics-container {
grid-row: 2 / 3;
grid-column: 4 / 7;
grid-area: metrics;
div:first-child {
padding: 12px;

View File

@ -0,0 +1,71 @@
<div class="topology-metrics-popover">
{{#if (and (not @item.Intention.Allowed) (not @item.Intention.HasPermissions))}}
<button
{{on "click" this.togglePopover}}
type="button"
class="deny-target"
style={{{ concat 'top:' @position.y 'px;left:' @position.x 'px;'}}}
>
<EmberPopover
@isShown={{this.showToggleablePopover}}
@event="none"
@hideOn="mouseleave"
@side="bottom-start"
@tooltipClass="deny-popover"
>
<div class="body">
<h3>Connection Denied</h3>
<p>Add an intention that allows these two services to connect.</p>
</div>
<div class="actions">
{{#if @item.Intention.HasExact}}
<a href={{href-to 'dc.services.show.intentions.edit' (concat @item.Intention.ID)}}>Edit</a>
{{else}}
<button
{{on "click" @oncreate}}
type="button"
>
Create
</button>
{{/if}}
<button
{{on "click" this.togglePopover}}
class="cancel"
>
Cancel
</button>
</div>
</EmberPopover>
</button>
{{else if @item.Intention.HasPermissions}}
<button
{{on "click" this.togglePopover}}
type="button"
class="L7-target"
style={{{ concat 'top:' @position.y 'px;left:' @position.x 'px;'}}}
>
<EmberPopover
@isShown={{this.showToggleablePopover}}
@event="none"
@hideOn="mouseleave"
@side="bottom-start"
@tooltipClass="L7-popover"
>
<div class="body">
<h3>Layer 7 permissions</h3>
<p>Certain HTTP request info must be identified.</p>
</div>
<div class="actions">
<a href={{href-to 'dc.services.show.intentions.edit' (concat @item.Intention.ID)}}>View</a>
<button
{{on "click" this.togglePopover}}
>
Close
</button>
</div>
</EmberPopover>
</button>
{{/if}}
</div>

View File

@ -0,0 +1,14 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
export default class TopoloyMetricsButton extends Component {
// =attributes
@tracked showToggleablePopover = false;
// =actions
@action
togglePopover() {
this.showToggleablePopover = !this.showToggleablePopover;
}
}

View File

@ -0,0 +1,87 @@
.topology-metrics-popover {
.deny-target,
.L7-target {
transform: translate(-50%,-50%);
position: absolute;
background-color: $white;
padding: 1px 2px;
}
.deny-target:hover,
.L7-target:hover {
cursor:pointer;
}
.deny-target:active,
.deny-target:focus,
.L7-target:active,
.L7-target:focus {
outline: none;
}
.deny-target::before {
@extend %with-cancel-square-fill-color-mask, %as-pseudo;
background-color: $red-500;
}
.L7-target::before {
@extend %with-layers-mask, %as-pseudo;
background-color: $gray-300;
}
.body {
padding: 12px 12px 16px 25px;
h3 {
font-size: 14px;
padding-bottom: 4px;
}
p {
font-size: 12px;
}
}
.actions {
border-top: 1px solid $gray-300;
width: 100%;
display: inline-flex;
a,
button {
width: 50%;
height: 36px;
padding: 10px 0;
font-weight: $typo-weight-medium;
text-align: center;
line-height: normal;
}
button:first-child {
color: $blue-500;
}
button:nth-child(2) {
color: $gray-800;
}
button:hover {
cursor: pointer;
}
}
.ember-popover {
padding: 0;
width: 200px;
z-index: 3 !important;
}
}
.L7-popover {
.body {
background-color: $white;
}
h3 {
margin-left: -20px;
color: $blue-500;
}
h3::before {
@extend %with-info-circle-fill-mask, %as-pseudo;
color: $blue-500;
margin-right: 4px;
}
}
.deny-popover {
.body {
background-color: $white;
}
h3 {
color: $red-800;
}
}

View File

@ -4,7 +4,6 @@
padding: 0;
margin: 0;
height: 70px;
z-index: 2;
svg.sparkline {
width: 100%;
@ -16,7 +15,7 @@
.tooltip {
visibility: hidden;
position: absolute;
z-index: 100;
z-index: 10;
bottom: 78px;
width: 217px;
}

View File

@ -123,22 +123,3 @@
stroke-linejoin: round;
}
}
// Icon on SVG Lines
#downstream-lines,
#upstream-lines {
.deny::before {
@extend %with-cancel-square-fill-color-mask, %as-pseudo;
background-color: $red-500;
}
.L7::before {
@extend %with-layers-mask, %as-pseudo;
background-color: $gray-300;
}
span {
transform: translate(-50%,-50%);
position: absolute;
background-color: $white;
}
}

View File

@ -1,15 +1,13 @@
.stats {
padding: 12px;
padding: 12px 12px 0 12px;
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: stretch;
width: 100%;
height: 46px;
overflow: hidden;
dl {
display:flex;
margin-bottom: 50px; // pushes wrapped metrics well out of the bounding box to hide them.
padding-bottom: 12px;
}
dt {
margin-right: 5px;

View File

@ -55,7 +55,10 @@
</svg>
{{/if}}
<TopologyMetrics::Icon
@positions={{this.iconPositions}}
@items={{@items}}
/>
{{#each @items as |item|}}
<TopologyMetrics::Popover
@position={{find-by 'id' (concat item.Namespace item.Name) this.iconPositions}}
@item={{item}}
@oncreate={{action @oncreate @service item}}
/>
{{/each}}

View File

@ -2,7 +2,7 @@ import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class TopoloyMetricsUpLines extends Component {
export default class TopologyMetricsUpLines extends Component {
@tracked iconPositions;
@action

View File

@ -2,13 +2,27 @@ import { inject as service } from '@ember/service';
import Route from 'consul-ui/routing/route';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import { action, setProperties } from '@ember/object';
export default class ShowRoute extends Route {
@service('data-source/service')
data;
@service('data-source/service') data;
@service('repository/intention') repo;
@service('ui-config') config;
@service('ui-config')
config;
@action
async createIntention(source, destination) {
const intention = service.Intention;
const model = this.repo.create({
Datacenter: source.Datacenter,
SourceName: source.Name,
SourceNS: source.Namespace || 'default',
DestinationName: destination.Name,
DestinationNS: destination.Namespace || 'default',
Action: 'allow',
});
await this.repo.persist(model);
this.refresh();
}
model(params, transition) {
const dc = this.modelFor('dc').dc.Name;

View File

@ -1,13 +1,31 @@
import Serializer from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/topology';
import { inject as service } from '@ember/service';
export default class TopologySerializer extends Serializer {
@service('store') store;
primaryKey = PRIMARY_KEY;
slugKey = SLUG_KEY;
respondForQueryRecord(respond, query) {
const intentionSerializer = this.store.serializerFor('intention');
return super.respondForQueryRecord(function(cb) {
return respond(function(headers, body) {
body.Downstreams.forEach(item => {
item.Intention.SourceName = item.Name;
item.Intention.SourceNS = item.Namespace;
item.Intention.DestinationName = query.id;
item.Intention.DestinationNS = query.ns || 'default';
intentionSerializer.ensureID(item.Intention);
});
body.Upstreams.forEach(item => {
item.Intention.SourceName = query.id;
item.Intention.SourceNS = query.ns || 'default';
item.Intention.DestinationName = item.Name;
item.Intention.DestinationNS = item.Namespace;
intentionSerializer.ensureID(item.Intention);
});
return cb(headers, {
...body,
[SLUG_KEY]: query.id,

View File

@ -62,6 +62,7 @@
@import 'consul-ui/components/consul/intention';
@import 'consul-ui/components/role-selector';
@import 'consul-ui/components/topology-metrics';
@import 'consul-ui/components/topology-metrics/popover';
@import 'consul-ui/components/topology-metrics/series';
@import 'consul-ui/components/topology-metrics/stats';
@import 'consul-ui/components/topology-metrics/status';

View File

@ -11,6 +11,8 @@
Datacenter=dc
Service=items.firstObject
)}}
@oncreate={{route-action 'createIntention'}}
/>
{{else}}
<EmptyState>

View File

@ -123,6 +123,7 @@
"ember-ref-modifier": "^1.0.0",
"ember-render-helpers": "^0.1.1",
"ember-resolver": "^8.0.0",
"ember-route-action-helper": "^2.0.8",
"ember-router-helpers": "^0.4.0",
"ember-sinon-qunit": "5.0.0",
"ember-source": "~3.20.5",

View File

@ -10,8 +10,9 @@ module('Integration | Serializer | topology', function(hooks) {
const serializer = this.owner.lookup('serializer:topology');
const dc = 'dc-1';
const id = 'slug';
const kind = '';
const request = {
url: `/v1/internal/ui/service-topology/${id}?dc=${dc}`,
url: `/v1/internal/ui/service-topology/${id}?dc=${dc}&kind=${kind}`,
};
return get(request.url).then(function(payload) {
const expected = {
@ -28,6 +29,7 @@ module('Integration | Serializer | topology', function(hooks) {
{
dc: dc,
id: id,
kind: kind,
}
);
assert.equal(actual.Datacenter, expected.Datacenter);

View File

@ -8031,6 +8031,13 @@ ember-export-application-global@^2.0.1:
resolved "https://registry.yarnpkg.com/ember-export-application-global/-/ember-export-application-global-2.0.1.tgz#b120a70e322ab208defc9e2daebe8d0dfc2dcd46"
integrity sha512-B7wiurPgsxsSGzJuPFkpBWnaeuCu2PGpG2BjyrfA1VcL7//o+5RSnZqiCEY326y7qmxb2GoCgo0ft03KBU0rRw==
ember-factory-for-polyfill@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/ember-factory-for-polyfill/-/ember-factory-for-polyfill-1.3.1.tgz#b446ed64916d293c847a4955240eb2c993b86eae"
integrity sha512-y3iG2iCzH96lZMTWQw6LWNLAfOmDC4pXKbZP6FxG8lt7GGaNFkZjwsf+Z5GAe7kxfD7UG4lVkF7x37K82rySGA==
dependencies:
ember-cli-version-checker "^2.1.0"
ember-get-config@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/ember-get-config/-/ember-get-config-0.2.4.tgz#118492a2a03d73e46004ed777928942021fe1ecd"
@ -8039,6 +8046,14 @@ ember-get-config@^0.2.4:
broccoli-file-creator "^1.1.1"
ember-cli-babel "^6.3.0"
ember-getowner-polyfill@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/ember-getowner-polyfill/-/ember-getowner-polyfill-2.2.0.tgz#38e7dccbcac69d5ec694000329ec0b2be651d2b2"
integrity sha512-rwGMJgbGzxIAiWYjdpAh04Abvt0s3HuS/VjHzUFhVyVg2pzAuz45B9AzOxYXzkp88vFC7FPaiA4kE8NxNk4A4Q==
dependencies:
ember-cli-version-checker "^2.1.0"
ember-factory-for-polyfill "^1.3.1"
ember-href-to@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/ember-href-to/-/ember-href-to-3.1.0.tgz#704f66c2b555a2685fac9ddc74eb9c95abaf5b8f"
@ -8222,6 +8237,14 @@ ember-rfc176-data@^0.3.12, ember-rfc176-data@^0.3.13, ember-rfc176-data@^0.3.16:
resolved "https://registry.yarnpkg.com/ember-rfc176-data/-/ember-rfc176-data-0.3.16.tgz#2ace0ac9cf9016d493a74a1d931643a308679803"
integrity sha512-IYAzffS90r2ybAcx8c2qprYfkxa70G+/UPkxMN1hw55DU5S2aLOX6v3umKDZItoRhrvZMCnzwsdfKSrKdC9Wbg==
ember-route-action-helper@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/ember-route-action-helper/-/ember-route-action-helper-2.0.8.tgz#f227fcccb73e839b65e9b814e241b322fe8c02fc"
integrity sha512-V+4uKwqaYveriVt2rl4e+9mzHJiQOr1B8dCPQQ2TS3iAcmi5RD2giRDFGtCK9d2XY9Arb/f9hJh0obP20iyt3A==
dependencies:
ember-cli-babel "^6.8.1"
ember-getowner-polyfill "^2.0.0"
ember-router-generator@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ember-router-generator/-/ember-router-generator-2.0.0.tgz#d04abfed4ba8b42d166477bbce47fccc672dbde0"