ui: Discovery Chain (#6746)

* Add data layer for discovery chain (model/adapter/serializer/repo)

* Add routing plus template for routing tab

* Add extra deps - consul-api-double upgrade plus ngraph for graphing

* Add discovery-chain and related components and helpers:

1. discovery-chain to orchestrate/view controller
2. route-card, splitter-card, resolver card to represent the 3 different
node types.
3. route-match helper for easy formatting of route rules
4. dom-position to figure out where things are in order to draw lines
5. svg-curve, simple wrapper around svg's <path d=""> attribute format.
6. data-structs service. This isn't super required but we are using
other data-structures provided by other third party npm modules in other
yet to be merged PRs. All of these types of things will live here for
easy access/injection/changability
7. Some additions to our css-var 'polyfill' for a couple of extra needed
rules

* Related CSS for discovery chain

1. We add a %card base component here, eventually this will go into our
base folder and %stats-card will also use it for a base component.
2. New icon for failovers

* ui: Discovery Chain Continued (#6939)

1. Add in the things we use for the animations
2 Use IntersectionObserver so we know when the tab is visible,
otherwise the dom-position helper won't work as the dom elements don't
have any display.
3. Add some base work for animations and use them a little
4. Try to detect if a resolver is a redirect. Right now this works for
datacenters and namespaces, but it can't work for services and subsets -
we are awaiting backend support for doing this properly.
5. Add a fake 'this service has no routes' route that says 'Default'
6. redirect icon
7. Add CSS.escape polyfill for Edge
This commit is contained in:
John Cowen 2019-12-17 19:27:28 +00:00 committed by John Cowen
parent 7044aa52c8
commit aa680d5f0c
60 changed files with 1782 additions and 5 deletions

View File

@ -0,0 +1,14 @@
import Adapter from './application';
export default Adapter.extend({
requestForQueryRecord: function(request, { dc, index, id }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
return request`
GET /v1/discovery-chain/${id}?${{ dc }}
${{ index }}
`;
},
});

View File

@ -0,0 +1,288 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { set, get, computed } from '@ember/object';
import { next } from '@ember/runloop';
const getNodesByType = function(nodes = {}, type) {
return Object.values(nodes).filter(item => item.Type === type);
};
const targetsToFailover = function(targets, a) {
let type;
const Targets = targets.map(function(b) {
// FIXME: this isn't going to work past namespace for services
// with dots in the name
const [aRev, bRev] = [a, b].map(item => item.split('.').reverse());
const types = ['Datacenter', 'Namespace', 'Service', 'Subset'];
return bRev.find(function(item, i) {
const res = item !== aRev[i];
if (res) {
type = types[i];
}
return res;
});
});
return {
Type: type,
Targets: Targets,
};
};
const getNodeResolvers = function(nodes = {}) {
const failovers = getFailovers(nodes);
const resolvers = {};
Object.keys(nodes).forEach(function(key) {
const node = nodes[key];
if (node.Type === 'resolver' && !failovers.includes(key.split(':').pop())) {
resolvers[node.Name] = node;
}
});
return resolvers;
};
const getTargetResolvers = function(dc, nspace = 'default', targets = [], nodes = {}) {
const resolvers = {};
Object.values(targets).forEach(item => {
let node = nodes[item.ID];
if (node) {
if (typeof resolvers[item.Service] === 'undefined') {
resolvers[item.Service] = {
ID: item.ID,
Name: item.Service,
Children: [],
Failover: null,
Redirect: null,
};
}
const resolver = resolvers[item.Service];
let failoverable = resolver;
if (item.ServiceSubset) {
failoverable = item;
// FIXME: Sometimes we have set the resolvers ID to the ID of the
// subset this just shifts the subset of the front of the URL for the moment
const temp = item.ID.split('.');
temp.shift();
resolver.ID = temp.join('.');
resolver.Children.push(item);
}
if (typeof node.Resolver.Failover !== 'undefined') {
// FIXME: Figure out if we can get rid of this
/* eslint ember/no-side-effects: "warn" */
set(failoverable, 'Failover', targetsToFailover(node.Resolver.Failover.Targets, item.ID));
} else {
const res = targetsToFailover([node.Resolver.Target], `service.${nspace}.${dc}`);
if (res.Type === 'Datacenter' || res.Type === 'Namespace') {
resolver.Children.push(item);
set(failoverable, 'Redirect', true);
}
}
}
});
return Object.values(resolvers);
};
const getFailovers = function(nodes = {}) {
const failovers = [];
Object.values(nodes)
.filter(item => item.Type === 'resolver')
.forEach(function(item) {
(get(item, 'Resolver.Failover.Targets') || []).forEach(failover => {
failovers.push(failover);
});
});
return failovers;
};
export default Component.extend({
dom: service('dom'),
ticker: service('ticker'),
dataStructs: service('data-structs'),
classNames: ['discovery-chain'],
classNameBindings: ['active'],
isDisplayed: false,
selectedId: '',
x: 0,
y: 0,
tooltip: '',
activeTooltip: false,
init: function() {
this._super(...arguments);
this._listeners = this.dom.listeners();
this._viewportlistener = this.dom.listeners();
},
didInsertElement: function() {
this._super(...arguments);
this._viewportlistener.add(
this.dom.isInViewport(this.element, bool => {
set(this, 'isDisplayed', bool);
if (this.isDisplayed) {
this.addPathListeners();
} else {
this.ticker.destroy(this);
}
})
);
},
didReceiveAttrs: function() {
this._super(...arguments);
if (this.element) {
this.addPathListeners();
}
},
willDestroyElement: function() {
this._super(...arguments);
this._listeners.remove();
this._viewportlistener.remove();
this.ticker.destroy(this);
},
splitters: computed('chain.Nodes', function() {
return getNodesByType(get(this, 'chain.Nodes'), 'splitter').map(function(item) {
set(item, 'ID', `splitter:${item.Name}`);
return item;
});
}),
routers: computed('chain.Nodes', function() {
// Right now there should only ever be one 'Router'.
return getNodesByType(get(this, 'chain.Nodes'), 'router');
}),
routes: computed('chain', 'routers', function() {
const routes = get(this, 'routers').reduce(function(prev, item) {
return prev.concat(
item.Routes.map(function(route, i) {
return {
...route,
ID: `route:${item.Name}-${JSON.stringify(route.Definition.Match.HTTP)}`,
};
})
);
}, []);
if (routes.length === 0) {
let nextNode = `resolver:${this.chain.ServiceName}.${this.chain.Namespace}.${this.chain.Datacenter}`;
const splitterID = `splitter:${this.chain.ServiceName}`;
if (typeof this.chain.Nodes[splitterID] !== 'undefined') {
nextNode = splitterID;
}
routes.push({
Default: true,
ID: `route:${this.chain.ServiceName}`,
Name: this.chain.ServiceName,
Definition: {
Match: {
HTTP: {
PathPrefix: '/',
},
},
},
NextNode: nextNode,
});
}
return routes;
}),
nodeResolvers: computed('chain.Nodes', function() {
return getNodeResolvers(get(this, 'chain.Nodes'));
}),
resolvers: computed('nodeResolvers.[]', 'chain.Targets', function() {
return getTargetResolvers(
this.chain.Datacenter,
this.chain.Namespace,
get(this, 'chain.Targets'),
this.nodeResolvers
);
}),
graph: computed('chain.Nodes', function() {
const graph = this.dataStructs.graph();
Object.entries(get(this, 'chain.Nodes')).forEach(function([key, item]) {
switch (item.Type) {
case 'splitter':
item.Splits.forEach(function(splitter) {
graph.addLink(`splitter:${item.Name}`, splitter.NextNode);
});
break;
case 'router':
item.Routes.forEach(function(route, i) {
graph.addLink(
`route:${item.Name}-${JSON.stringify(route.Definition.Match.HTTP)}`,
route.NextNode
);
});
break;
}
});
return graph;
}),
selected: computed('selectedId', 'graph', function() {
if (this.selectedId === '' || !this.dom.element(`#${this.selectedId}`)) {
return {};
}
const getTypeFromId = function(id) {
return id.split(':').shift();
};
const id = this.selectedId;
const type = getTypeFromId(id);
const nodes = [id];
const edges = [];
this.graph.forEachLinkedNode(id, (linkedNode, link) => {
nodes.push(linkedNode.id);
edges.push(`${link.fromId}>${link.toId}`);
this.graph.forEachLinkedNode(linkedNode.id, (linkedNode, link) => {
const nodeType = getTypeFromId(linkedNode.id);
if (type !== nodeType && type !== 'splitter' && nodeType !== 'splitter') {
nodes.push(linkedNode.id);
edges.push(`${link.fromId}>${link.toId}`);
}
});
});
return {
nodes: nodes.map(item => `#${CSS.escape(item)}`),
edges: edges.map(item => `#${CSS.escape(item)}`),
};
}),
width: computed('isDisplayed', 'chain.{Nodes,Targets}', function() {
return this.element.offsetWidth;
}),
height: computed('isDisplayed', 'chain.{Nodes,Targets}', function() {
return this.element.offsetHeight;
}),
// TODO(octane): ember has trouble adding mouse events to svg elements whilst giving
// the developer access to the mouse event therefore we just use JS to add our events
// revisit this post Octane
addPathListeners: function() {
// FIXME: Figure out if we can remove this next
next(() => {
this._listeners.remove();
[...this.dom.elements('path.split', this.element)].forEach(item => {
this._listeners.add(item, {
mouseover: e => this.actions.showSplit.apply(this, [e]),
mouseout: e => this.actions.hideSplit.apply(this, [e]),
});
});
});
// TODO: currently don't think there is a way to listen
// for an element being removed inside a component, possibly
// using IntersectionObserver. It's a tiny detail, but we just always
// remove the tooltip on component update as its so tiny, ideal
// the tooltip would stay if there was no change to the <path>
// set(this, 'activeTooltip', false);
},
actions: {
showSplit: function(e) {
this.setProperties({
x: e.offsetX,
y: e.offsetY - 5,
tooltip: e.target.dataset.percentage,
activeTooltip: true,
});
},
hideSplit: function(e = null) {
set(this, 'activeTooltip', false);
},
click: function(e) {
const id = e.currentTarget.getAttribute('id');
if (id === this.selectedId) {
set(this, 'active', false);
set(this, 'selectedId', '');
} else {
set(this, 'active', true);
set(this, 'selectedId', id);
}
},
},
});

View File

@ -0,0 +1,5 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
});

View File

@ -0,0 +1,29 @@
import Component from '@ember/component';
import { get, computed } from '@ember/object';
export default Component.extend({
tagName: '',
path: computed('item', function() {
if (get(this, 'item.Default')) {
return {
type: 'Default',
value: '/',
};
}
return Object.entries(get(this, 'item.Definition.Match.HTTP') || {}).reduce(
function(prev, [key, value]) {
if (key.toLowerCase().startsWith('path')) {
return {
type: key.replace('Path', ''),
value: value,
};
}
return prev;
},
{
type: 'Prefix',
value: '/',
}
);
}),
});

View File

@ -0,0 +1,3 @@
import Component from '@ember/component';
export default Component.extend({});

View File

@ -19,6 +19,7 @@ export default Controller.extend(WithEventSource, WithSearching, {
// This method is called immediately after `Route::setupController`, and done here rather than there
// as this is a variable used purely for view level things, if the view was different we might not
// need this variable
set(this, 'selectedTab', 'instances');
},
item: listen('item').catch(function(e) {

View File

@ -1,6 +1,11 @@
import { helper } from '@ember/component/helper';
// TODO: Look at ember-inline-svg
const cssVars = {
'--decor-border-100': '1px solid',
'--decor-border-200': '2px solid',
'--decor-radius-300': '3px',
'--white': '#FFF',
'--gray-500': '#6f7682',
'--kubernetes-color-svg': `url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 21 20" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" stroke="%23FFF" fill="none"><path d="M10.21 1.002a1.241 1.241 0 0 0-.472.12L3.29 4.201a1.225 1.225 0 0 0-.667.83l-1.591 6.922a1.215 1.215 0 0 0 .238 1.035l4.463 5.55c.234.29.59.46.964.46l7.159-.002c.375 0 .73-.168.964-.459l4.462-5.55c.234-.292.322-.673.238-1.036l-1.593-6.921a1.225 1.225 0 0 0-.667-.83l-6.45-3.08a1.242 1.242 0 0 0-.598-.12z" fill="%23326CE5"/><path d="M10.275 3.357c-.213 0-.386.192-.386.429v.11c.005.136.035.24.052.367.033.27.06.492.043.7a.421.421 0 0 1-.125.2l-.01.163a4.965 4.965 0 0 0-3.22 1.548 6.47 6.47 0 0 1-.138-.099c-.07.01-.139.03-.23-.022-.172-.117-.33-.277-.52-.47-.087-.093-.15-.181-.254-.27L5.4 5.944a.46.46 0 0 0-.269-.101.372.372 0 0 0-.307.136c-.133.167-.09.422.094.57l.006.003.08.065c.11.08.21.122.32.187.231.142.422.26.574.403.06.063.07.175.078.223l.123.11a4.995 4.995 0 0 0-.787 3.483l-.162.047c-.042.055-.103.141-.166.167-.198.063-.422.086-.692.114-.126.01-.236.004-.37.03-.03.005-.07.016-.103.023l-.003.001-.006.002c-.228.055-.374.264-.327.47.047.206.27.331.498.282h.006c.003-.001.005-.003.008-.003l.1-.022c.131-.036.227-.088.346-.133.255-.092.467-.168.673-.198.086-.007.177.053.222.078l.168-.029a5.023 5.023 0 0 0 2.226 2.78l-.07.168c.025.065.053.154.034.218-.075.195-.203.4-.35.628-.07.106-.142.188-.206.309l-.05.104c-.099.212-.026.456.165.548.191.092.43-.005.532-.218h.001v-.001c.015-.03.036-.07.048-.098.055-.126.073-.233.111-.354.102-.257.159-.526.3-.694.038-.046.1-.063.166-.08l.087-.159a4.987 4.987 0 0 0 3.562.01l.083.148c.066.021.138.032.197.12.105.179.177.391.265.648.038.121.057.229.112.354.012.029.033.069.048.099.102.213.341.311.533.219.19-.092.264-.337.164-.549l-.05-.104c-.064-.12-.136-.202-.207-.307-.146-.23-.267-.419-.342-.613-.032-.1.005-.163.03-.228-.015-.017-.047-.111-.065-.156a5.023 5.023 0 0 0 2.225-2.8l.165.03c.058-.039.112-.088.216-.08.206.03.418.106.673.198.12.045.215.098.347.133.028.008.068.015.1.022l.007.002.006.001c.229.05.45-.076.498-.282.047-.206-.1-.415-.327-.47l-.112-.027c-.134-.025-.243-.019-.37-.03-.27-.027-.494-.05-.692-.113-.081-.031-.139-.128-.167-.167l-.156-.046a4.997 4.997 0 0 0-.804-3.474l.137-.123c.006-.069.001-.142.073-.218.151-.143.343-.261.574-.404.11-.064.21-.106.32-.187.025-.018.06-.047.086-.068.185-.148.227-.403.094-.57-.133-.166-.39-.182-.575-.034-.027.02-.062.048-.086.068-.104.09-.168.178-.255.27-.19.194-.348.355-.52.471-.075.044-.185.029-.235.026l-.146.104A5.059 5.059 0 0 0 10.7 5.328a9.325 9.325 0 0 1-.009-.172c-.05-.048-.11-.09-.126-.193-.017-.208.011-.43.044-.7.018-.126.047-.23.053-.367l-.001-.11c0-.237-.173-.429-.386-.429zM9.79 6.351l-.114 2.025-.009.004a.34.34 0 0 1-.54.26l-.003.002-1.66-1.177A3.976 3.976 0 0 1 9.79 6.351zm.968 0a4.01 4.01 0 0 1 2.313 1.115l-1.65 1.17-.006-.003a.34.34 0 0 1-.54-.26h-.003L10.76 6.35zm-3.896 1.87l1.516 1.357-.002.008a.34.34 0 0 1-.134.585l-.001.006-1.944.561a3.975 3.975 0 0 1 .565-2.516zm6.813.001a4.025 4.025 0 0 1 .582 2.51l-1.954-.563-.001-.008a.34.34 0 0 1-.134-.585v-.004l1.507-1.35zm-3.712 1.46h.62l.387.483-.139.602-.557.268-.56-.269-.138-.602.387-.482zm1.99 1.652a.339.339 0 0 1 .08.005l.002-.004 2.01.34a3.98 3.98 0 0 1-1.609 2.022l-.78-1.885.002-.003a.34.34 0 0 1 .296-.475zm-3.375.008a.34.34 0 0 1 .308.474l.005.007-.772 1.866a3.997 3.997 0 0 1-1.604-2.007l1.993-.339.003.005a.345.345 0 0 1 .067-.006zm1.683.817a.338.338 0 0 1 .312.179h.008l.982 1.775a3.991 3.991 0 0 1-2.57-.002l.979-1.772h.001a.34.34 0 0 1 .288-.18z" stroke-width=".25" fill="%23FFF"/></g></svg>')`,
'--terraform-color-svg': `url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 16 18" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="%235C4EE5" d="M5.51 3.15l4.886 2.821v5.644L5.509 8.792z"/><path fill="%234040B2" d="M10.931 5.971v5.644l4.888-2.823V3.15z"/><path fill="%235C4EE5" d="M.086 0v5.642l4.887 2.823V2.82zM5.51 15.053l4.886 2.823v-5.644l-4.887-2.82z"/></g></svg>')`,
'--nomad-color-svg': `url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 16 18" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path fill="%231F9967" d="M11.569 6.871v2.965l-2.064 1.192-1.443-.894v7.74l.04.002 7.78-4.47V4.48h-.145z"/><path fill="%2325BA81" d="M7.997 0L.24 4.481l5.233 3.074 1.06-.645 2.57 1.435v-2.98l2.465-1.481v2.987l4.314-2.391v-.011z"/><path fill="%2325BA81" d="M7.02 9.54v2.976l-2.347 1.488V8.05l.89-.548L.287 4.48.24 4.48v8.926l7.821 4.467v-7.74z"/></g></svg>')`,

View File

@ -0,0 +1,28 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
export default Helper.extend({
dom: service('dom'),
compute: function([selector, id], hash) {
const $el = this.dom.element(selector);
const $refs = [$el.offsetParent, $el];
// TODO: helper probably needs to accept a `reference=` option
// with a selector to use as reference/root
if (selector.startsWith('#resolver:')) {
$refs.unshift($refs[0].offsetParent);
}
return $refs.reduce(
function(prev, item) {
prev.x += item.offsetLeft;
prev.y += item.offsetTop;
return prev;
},
{
x: 0,
y: 0,
height: $el.offsetHeight,
width: $el.offsetWidth,
}
);
},
});

View File

@ -0,0 +1,18 @@
import { helper } from '@ember/component/helper';
export default helper(function routeMatch([params] /*, hash*/) {
const keys = Object.keys(params);
switch (true) {
case keys.includes('Present'):
return `${params.Invert ? `NOT ` : ``}present`;
case keys.includes('Exact'):
return `${params.Invert ? `NOT ` : ``}exactly matching "${params.Exact}"`;
case keys.includes('Prefix'):
return `${params.Invert ? `NOT ` : ``}prefixed by "${params.Prefix}"`;
case keys.includes('Suffix'):
return `${params.Invert ? `NOT ` : ``}suffixed by "${params.Suffix}"`;
case keys.includes('Regex'):
return `${params.Invert ? `NOT ` : ``}matching the regex "${params.Regex}"`;
}
return '';
});

View File

@ -0,0 +1,41 @@
import { helper } from '@ember/component/helper';
// arguments should be a list of {x: numLike, y: numLike} points
// numLike meaning they should be numbers (or numberlike strings i.e. "1" vs 1)
const curve = function() {
const args = [...arguments];
// our arguments are destination first control points last
// SVGs are control points first destination last
// we 'shift,push' to turn that around and then map
// through the values to convert it to 'x y, x y' etc
// whether the curve is cubic-bezier (C) or quadratic-bezier (Q)
// then depends on the amount of control points
// `Q|C x y, x y, x y` etc
return `${arguments.length > 2 ? `C` : `Q`} ${args
.concat(args.shift())
.map(p => Object.values(p).join(' '))
.join(',')}`;
};
const move = function(d) {
return `
M ${d.x} ${d.y}
`;
};
export default helper(function([dest], hash) {
const src = hash.src || { x: 0, y: 0 };
const type = hash.type || 'cubic';
let args = [
dest,
{
x: (src.x + dest.x) / 2,
y: src.y,
},
];
if (type === 'cubic') {
args.push({
x: args[1].x,
y: dest.y,
});
}
return `${move(src)}${curve(...args)}`;
});

View File

@ -0,0 +1,9 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
export default Helper.extend({
ticker: service('ticker'),
compute: function([props, id], hash) {
return this.ticker.tweenTo(props, id);
},
});

View File

@ -4,7 +4,7 @@ export function initialize(container) {
if (env('CONSUL_UI_DISABLE_REALTIME')) {
return;
}
['node', 'coordinate', 'session', 'service', 'proxy']
['node', 'coordinate', 'session', 'service', 'proxy', 'discovery-chain']
.concat(config('CONSUL_NSPACES_ENABLED') ? ['nspace/enabled'] : [])
.map(function(item) {
// create repositories that return a promise resolving to an EventSource
@ -60,6 +60,7 @@ export function initialize(container) {
route: 'dc/services/show',
services: {
repo: 'repository/service/event-source',
chainRepo: 'repository/discovery-chain/event-source',
},
},
{

View File

@ -0,0 +1,12 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
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'),
Chain: attr(),
meta: attr(),
});

View File

@ -4,6 +4,7 @@ import { hash } from 'rsvp';
export default Route.extend({
repo: service('repository/service'),
chainRepo: service('repository/discovery-chain'),
settings: service('settings'),
queryParams: {
s: {
@ -15,6 +16,7 @@ export default Route.extend({
const dc = this.modelFor('dc').dc.Name;
return hash({
item: this.repo.findBySlug(params.name, dc, this.modelFor('nspace').nspace.substr(1)),
chain: this.chainRepo.findBySlug(params.name, dc),
urls: this.settings.findBySlug('urls'),
dc: dc,
});

View File

@ -0,0 +1,17 @@
import Serializer from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/discovery-chain';
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]: body.Chain[SLUG_KEY],
});
});
}, query);
},
});

View File

@ -0,0 +1,9 @@
import Service from '@ember/service';
import createGraph from 'ngraph.graph';
export default Service.extend({
graph: function() {
return createGraph();
},
});

View File

@ -21,12 +21,14 @@ import clickFirstAnchorFactory from 'consul-ui/utils/dom/click-first-anchor';
// use $_ for components
const $$ = qsaFactory();
let $_;
let inViewportCallbacks;
const clickFirstAnchor = clickFirstAnchorFactory(closest);
export default Service.extend({
doc: document,
win: window,
init: function() {
this._super(...arguments);
inViewportCallbacks = new WeakMap();
$_ = getComponentFactory(getOwner(this));
},
document: function() {
@ -89,4 +91,24 @@ export default Service.extend({
return item != null;
});
},
isInViewport: function($el, cb, threshold = 0) {
inViewportCallbacks.set($el, cb);
const observer = new IntersectionObserver(
(entries, observer) => {
entries.map(item => {
const cb = inViewportCallbacks.get(item.target);
if (typeof cb === 'function') {
cb(item.isIntersecting);
}
});
},
{
rootMargin: '0px',
threshold: threshold,
}
);
observer.observe($el); // eslint-disable-line ember/no-observers
// observer.unobserve($el);
return () => observer.disconnect(); // eslint-disable-line ember/no-observers
},
});

View File

@ -0,0 +1,8 @@
import RepositoryService from 'consul-ui/services/repository';
const modelName = 'discovery-chain';
export default RepositoryService.extend({
getModelName: function() {
return modelName;
},
});

View File

@ -0,0 +1,35 @@
import Service from '@ember/service';
import { Tween } from 'consul-ui/utils/ticker';
let map;
export default Service.extend({
init: function() {
this._super(...arguments);
this.reset();
},
tweenTo: function(props, obj = '', frames, method) {
// TODO: Right now we only support string id's
// but potentially look at allowing passing of other objects
// especially DOM elements
const id = obj;
if (!map.has(id)) {
map.set(id, props);
return props;
} else {
obj = map.get(id);
if (obj instanceof Tween) {
obj = obj.stop().getTarget();
}
map.set(id, Tween.to(obj, props, frames, method));
return obj;
}
},
// TODO: We'll try and use obj later for ticker bookkeeping
destroy: function(obj) {
this.reset();
return Tween.destroy();
},
reset: function() {
map = new Map();
},
});

View File

@ -28,6 +28,7 @@ $chevron-up-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24"
$chevron-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="10" height="6" viewBox="0 0 10 6" xmlns="http://www.w3.org/2000/svg"><path d="M5.001 3.515L8.293.287a1.014 1.014 0 0 1 1.414 0 .967.967 0 0 1 0 1.386L5.71 5.595a1.014 1.014 0 0 1-1.414 0L.293 1.674a.967.967 0 0 1 0-1.387 1.014 1.014 0 0 1 1.414 0l3.294 3.228z" fill="%23000" fill-rule="nonzero"/></svg>');
$clock-fill-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm4.2 14.2L11 13V7h1.5v5.2l4.5 2.7-.8 1.3z" fill="%23000"/></svg>');
$clock-outline-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z" fill="%23000"/></svg>');
$cloud-cross-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 23 20" xmlns="http://www.w3.org/2000/svg"><path d="M12 13.586l3-3L16.414 12l-3 3 3 3L15 19.414l-3-3-3 3L7.586 18l3-3-2.75-2.75 1.414-1.414 2.75 2.75zM13.905.856c2.706.498 4.595 2.544 4.816 5.64l.234.066c2.46.754 3.884 2.79 3.5 5.036-.445 2.592-2.083 4.371-5.876 4.371v-2c5.054 0 4.868-5.46.23-6.152.242-2.277-1.278-4.628-3.267-4.995-1.789-.33-3.757.564-4.572 2.164-1.629-.727-2.605-.345-3.442.362-.836.706-.527 2.194-.225 2.823-1.089.915-1.901 2.013-2.152 2.987-.101.395-.091 1.014 0 1.212.428.928 1.016 1.6 4.403 1.6v2c-4.117 0-5.672-.96-6.448-2.874-.652-1.61.137-3.457 1.882-5.118-.544-1.734.01-3.297 1.391-4.267a4.89 4.89 0 013.689-.77C9.222 1.26 11.603.431 13.905.855z" fill="%236F7682" fill-rule="nonzero"/></svg>');
$code-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" fill="%23000"/></svg>');
$consul-logo-color-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M8.693 10.707a1.862 1.862 0 1 1-.006-3.724 1.862 1.862 0 0 1 .006 3.724" fill="%23961D59"/><path d="M12.336 9.776a.853.853 0 1 1 0-1.707.853.853 0 0 1 0 1.707M15.639 10.556a.853.853 0 1 1 .017-.07c-.01.022-.01.044-.017.07M14.863 8.356a.855.855 0 0 1-.925-1.279.855.855 0 0 1 1.559.255c.024.11.027.222.009.333a.821.821 0 0 1-.642.691M17.977 10.467a.849.849 0 1 1-1.67-.296.849.849 0 0 1 .982-.692c.433.073.74.465.709.905a.221.221 0 0 0-.016.076M17.286 8.368a.853.853 0 1 1-.279-1.684.853.853 0 0 1 .279 1.684M16.651 13.371a.853.853 0 1 1-1.492-.828.853.853 0 0 1 1.492.828M16.325 5.631a.853.853 0 1 1-.84-1.485.853.853 0 0 1 .84 1.485" fill="%23D62783"/><path d="M8.842 17.534c-4.798 0-8.687-3.855-8.687-8.612C.155 4.166 4.045.31 8.842.31a8.645 8.645 0 0 1 5.279 1.77l-1.056 1.372a6.987 6.987 0 0 0-7.297-.709 6.872 6.872 0 0 0 0 12.356 6.987 6.987 0 0 0 7.297-.709l1.056 1.374a8.66 8.66 0 0 1-5.279 1.77z" fill="%23D62783" fill-rule="nonzero"/></g></svg>');
$copy-action-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.82 3C13.4 1.84 12.3 1 11 1c-1.3 0-2.4.84-2.82 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-4.18zM9 13H6v2h3v-2zm-3 6h5v-2H6v2zM6 9v2h6V9H6zm5-6c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm9.003 10H23v2h-2.997v2H18v4H4V5h2v1.007h10V5h2v5.992h2.003V13zm0 0H15.99v-3L12 14l3.99 4v-3h4.013v-2z" fill="%23000"/></svg>');
@ -81,6 +82,7 @@ $plus-circle-outline-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0
$plus-plain-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z" fill="%23000"/></svg>');
$plus-square-fill-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" fill="%23000"/></svg>');
$queue-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 9H2v2h17V9zm0-5H2v2h17V4zM2 16h13v-2H2v2zm20 4v-6l-5 3 5 3z" fill="%23000"/></svg>');
$redirect-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M2 19.833C2.89 13.318 6.712 9.65 13.466 8.83V4L22 11.77l-8.534 7.676v-4.849C8.585 14.344 4.763 16.09 2 19.833z" fill="%236F7682"/></svg>');
$refresh-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" fill="%23000"/></svg>');
$run-svg: url('data:image/svg+xml;charset=UTF-8,<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" class="structure-icon-run"><style>.structure-icon-run {animation: structure-icon-run-simple-spin 1s infinite linear;}.structure-icon-run-progress {animation: structure-icon-run-fancy-spin 3s infinite linear;fill: transparent;opacity: 0.66;stroke-dasharray: 16 16;transform-origin: 50% 50%;}@keyframes structure-icon-run-fancy-spin {0% {stroke-dasharray: 4 32;}50% {stroke-dasharray: 24 8;}50% {stroke-dasharray: 4 32;}50% {stroke-dasharray: 24 8;}100% {stroke-dasharray: 4 32;}}@keyframes structure-icon-run-simple-spin {0% {transform: rotate(0deg);}100% {transform: rotate(360deg);}}</style><g fill="none" fill-rule="evenodd"><circle cx="12" cy="12" r="8" stroke="%23000" stroke-width="4"/><circle cx="12" cy="12" r="5" stroke="currentColor" stroke-width="2" class="structure-icon-run-progress"/><circle cx="12" cy="12" r="4" fill="currentColor"/></g></svg>');
$search-color-svg: url('data:image/svg+xml;charset=UTF-8,<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" fill="%231563ff"/></svg>');

View File

@ -159,6 +159,11 @@
background-image: $clock-outline-svg;
}
%with-cloud-cross-icon {
@extend %with-icon;
background-image: $cloud-cross-svg;
}
%with-code-icon {
@extend %with-icon;
background-image: $code-svg;
@ -428,6 +433,11 @@
background-image: $queue-svg;
}
%with-redirect-icon {
@extend %with-icon;
background-image: $redirect-svg;
}
%with-refresh-icon {
@extend %with-icon;
background-image: $refresh-svg;

View File

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

View File

@ -0,0 +1,15 @@
%card {
display: block;
position: relative;
}
%card > header {
padding: 10px;
}
%card > section,
%card > ul > li {
padding: 5px 10px;
}
%card ul {
margin: 0;
padding: 0;
}

View File

@ -0,0 +1,25 @@
%card:hover,
%card:focus {
@extend %card-intent;
}
%card {
border: $decor-border-100;
border-radius: $decor-radius-100;
background-color: rgba($white, 0.9);
}
%card > section,
%card > ul > li {
border-top: $decor-border-100;
}
%card,
%card > section,
%card > ul > li {
border-color: $gray-200;
}
%card ul {
/*TODO: %list-style-none?*/
list-style-type: none;
}
%card-intent {
box-shadow: 0 8px 10px 0 rgba(0, 0, 0, 0.1);
}

View File

@ -0,0 +1,2 @@
@import './card/index';
@import './discovery-chain/index';

View File

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

View File

@ -0,0 +1,168 @@
.discovery-chain {
@extend %discovery-chain;
}
%discovery-chain .resolvers,
%discovery-chain .splitters,
%discovery-chain .routes {
@extend %chain-group;
}
%discovery-chain .route-card {
@extend %route-card;
@extend %with-chain-outlet;
}
%discovery-chain .splitter-card {
@extend %splitter-card;
@extend %with-chain-outlet;
}
%discovery-chain .resolver-card {
@extend %resolver-card;
}
%discovery-chain path {
@extend %discovery-chain-edge, %discovery-chain-edge-inactive;
}
%discovery-chain-edge:hover,
%discovery-chain-edge:focus {
@extend %discovery-chain-edge-active;
}
%discovery-chain [id*=':']:not(path):hover {
@extend %chain-node-active;
}
%discovery-chain .tooltip.active > [role='tooltip'],
%discovery-chain .tooltip.active > [role='tooltip']::after {
@extend %blink-in-fade-out-active;
}
%resolver-card dt {
@extend %with-pseudo-tooltip;
}
%discovery-chain {
position: relative;
display: flex;
justify-content: space-between;
}
%discovery-chain svg {
position: absolute;
}
%chain-group {
padding: 10px 1% 10px 1%;
width: 32%;
}
%chain-group > header span {
position: relative;
z-index: 1;
margin-left: 2px;
}
%chain-group [role='group'] {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: space-around;
height: 100%;
}
/* TODO: resets - these probably should be changed to be specific */
/* to certain layouts rather than globally */
%chain-node {
margin-top: 0 !important;
}
%chain-node dl {
margin: 0;
float: none;
}
/**/
%route-card,
%splitter-card,
%resolver-card {
@extend %card;
@extend %chain-node;
margin-bottom: 20px;
}
/* route */
%route-card header.short dl {
display: flex;
}
%route-card header.short dt::after {
content: ' ';
display: inline-block;
}
%route-card > header ul {
float: right;
margin-top: -2px;
}
%route-card > header ul li {
margin-left: 5px;
}
%route-card section {
display: flex;
}
%route-card section header {
display: block;
width: 33px;
}
%route-card section header > *::before {
padding: 0 8px;
}
/**/
/* resolver */
%resolver-card a {
display: block;
}
%resolver-card dl {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
}
%resolver-card dt {
margin-right: 6px;
margin-top: 1px;
width: 23px;
height: 20px;
}
%resolver-card ol {
display: flex;
flex-wrap: wrap;
list-style-type: none;
}
%resolver-card ol li:not(:last-child)::after {
display: inline-block;
content: ',';
margin-right: 0.2em;
margin-left: -0.3em;
}
/**/
%with-chain-outlet {
position: relative;
}
%with-chain-outlet::before {
position: absolute;
z-index: 1;
right: -5px;
top: 50%;
margin-top: -5px;
width: 10px;
height: 10px;
}
%discovery-chain-joints {
width: 10px;
height: 100%;
z-index: 1;
}
%discovery-chain .splitter-inlets,
%discovery-chain .resolver-inlets {
@extend %discovery-chain-joints;
}
/* there are 3 columns, or %chain-groups the calculation here */
/* depends on the width of those */
%discovery-chain .splitter-inlets {
left: 50%;
/* keep the extra calculations here for doc purposes */
margin-left: calc(calc(-32% / 2) + 1% - 3px);
}
%discovery-chain .resolver-inlets {
/* keep the extra calculations here for doc purposes */
right: calc(32% - 1% - 7px);
}

View File

@ -0,0 +1,126 @@
/* CSS active states are partly added at the top of */
/* components/templates/discovery-chain.hbs for reasons */
/* the styling there almost 100% uses our CSS vars */
/* defined in our CSS files, but be sure to */
/* take a look in the discovery-chain.hbs */
%discovery-chain .tooltip,
%chain-group > header span {
@extend %with-tooltip;
}
%route-card > header ul li {
@extend %pill;
}
%discovery-chain-tween {
transition-duration: 0.1s;
transition-timing-function: linear;
}
%discovery-chain-edge,
%chain-node {
cursor: pointer;
}
%discovery-chain-edge {
@extend %discovery-chain-tween;
transition-property: stroke;
fill: none;
stroke: $gray-400;
stroke-width: 2;
vector-effect: non-scaling-stroke;
}
%discovery-chain.active [id*=':'],
%chain-node-inactive,
%discovery-chain-edge-inactive {
opacity: 0.5;
}
%chain-node,
%chain-node a {
color: $gray-900 !important;
}
%discovery-chain-edge-active {
stroke: $gray-900;
}
%chain-group {
border-radius: $decor-radius-100;
border: 1px solid $gray-200;
background-color: $gray-100;
pointer-events: none;
}
%chain-group > header span,
%chain-node {
pointer-events: all;
}
%chain-group > header > * {
text-transform: uppercase;
border: 0;
font-size: 12px;
font-weight: normal;
}
%chain-group > header span::after {
@extend %with-info-circle-outline-icon, %as-pseudo;
width: 1.2em;
height: 1.2em;
opacity: 0.6;
}
/* TODO: this is tooltip related, we also do this elsewhere */
/* so would be good to look and see if we can centralize this */
%chain-group > header span em {
text-transform: none;
width: 250px;
font-style: normal;
white-space: normal !important;
}
%chain-node {
@extend %discovery-chain-tween;
transition-property: opacity background-color border-color;
}
%chain-node-active {
opacity: 1;
background-color: $white;
border-color: $gray-500;
}
/* TODO: More text truncation, centralize */
%route-card header:not(.short) dd {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* Icons */
%route-card section header > * {
visibility: hidden;
}
%resolver-card dt,
%route-card section header > *::before {
background-color: $gray-100;
visibility: visible;
}
%route-card .match-headers header *::before {
content: 'H';
}
%route-card .match-queryparams header > *::before {
content: 'Q';
}
%resolver-card dt {
font-size: 0;
background-size: 80% 80%;
}
%resolver-card dl.failover dt {
@extend %with-cloud-cross-icon;
}
%resolver-card dl.redirect dt {
@extend %with-redirect-icon;
}
/**/
%with-chain-outlet::before {
@extend %as-pseudo;
background-color: $white;
border-radius: $decor-radius-full;
border: 2px solid $gray-400;
}
%discovery-chain circle {
stroke-width: 2;
stroke: $gray-400;
fill: $white;
}

View File

@ -32,3 +32,4 @@
@import './notice';
@import './tooltip';
@import './sort-control';
@import './discovery-chain';

View File

@ -75,6 +75,11 @@ pre code,
%button {
font-weight: $typo-weight-semibold;
}
%route-card section dt,
%route-card header:not(.short) dd,
%splitter-card > header {
font-weight: $typo-weight-bold;
}
%main-nav-horizontal-drop-nav-separator {
font-weight: $typo-weight-medium;
}
@ -85,7 +90,8 @@ pre code,
%pill,
%form-element > strong,
%tbody-th em,
%app-view h1 em {
%app-view h1 em,
%route-card header dt {
font-weight: $typo-weight-normal;
}

View File

@ -0,0 +1,141 @@
{{#if isDisplayed }}
<style>
{{#if selected.nodes }}
{{selected.nodes}} {
opacity: 1 !important;
background-color: {{css-var '--white'}};
border: {{css-var '--decor-border-100'}};
border-radius: {{css-var '--decor-radius-300'}};
border-color: {{css-var '--gray-500'}};
box-shadow: 0 8px 10px 0 rgba(0, 0, 0, 0.1);
}
{{/if}}
{{#if selected.edges }}
{{selected.edges}} {
opacity: 1;
}
{{/if}}
</style>
<div class="routes">
<header>
<h2>
{{chain.ServiceName}} Router
<span>
<em role="tooltip">Use routers to intercept traffic using L7 criteria such as path prefixes or http headers.</em>
</span>
</h2>
</header>
<div role="group">
{{#each routes as |item|}}
{{route-card item=item onclick=(action 'click')}}
{{/each}}
</div>
</div>
<div class="splitters">
<header>
<h2>
Splitters
<span>
<em role="tooltip">Splitters are configured to split incoming requests across different services or subsets of a single service.</em>
</span>
</h2>
</header>
<div role="group">
{{#each (sort-by 'Name' splitters) as |item|}}
{{splitter-card item=item onclick=(action 'click')}}
{{/each}}
</div>
</div>
<div class="resolvers">
<header>
<h2>
Resolvers
<span>
<em role="tooltip">Resolvers are used to define which instances of a service should satisfy discovery requests.</em>
</span>
</h2>
</header>
<div role="group">
{{#each (sort-by 'Name' resolvers) as |item|}}
{{resolver-card item=item onclick=(action 'click')}}
{{/each}}
</div>
</div>
<svg width="100%" height="100%" viewBox={{concat '0 0 ' width ' ' height}} preserveAspectRatio="none">
{{#each routes as |item|}}
{{#let (dom-position (concat '#' item.ID)) as |src|}}
{{#let (dom-position (concat '#' item.NextNode)) as |destRect|}}
{{#let (tween-to (hash
x=destRect.x
y=(add destRect.y (div destRect.height 2))
) (concat item.ID)) as |dest|}}
<path
id={{concat item.ID '>' item.NextNode}}
d={{
svg-curve (hash
x=dest.x
y=dest.y
) src=(hash
x=(add src.x src.width)
y=(add src.y (div src.height 2))
)}} />
{{/let}}
{{/let}}
{{/let}}
{{/each}}
{{#each splitters as |splitter|}}
{{#let (dom-position (concat '#' splitter.ID)) as |src|}}
{{#each splitter.Splits as |item index|}}
{{#let (dom-position (concat '#' item.NextNode)) as |destRect|}}
{{#let (tween-to (hash
x=destRect.x
y=(add destRect.y (div destRect.height 2))
) (concat splitter.ID '-' index)) as |dest|}}
<path
id={{concat 'splitter:' splitter.Name '>' item.NextNode}}
class="split"
data-percentage={{item.Weight}}
d={{
svg-curve (hash
x=dest.x
y=dest.y
) src=(hash
x=(add src.x src.width)
y=(add src.y (div src.height 2))
)}} />
{{/let}}
{{/let}}
{{/each}}
{{/let}}
{{/each}}
</svg>
<svg class="resolver-inlets" viewBox={{concat '0 0 10 ' height}}>
{{#each routes as |item|}}
{{#if (starts-with 'resolver:' item.NextNode) }}
{{#let (dom-position (concat '#' item.NextNode)) as |dest|}}
<circle r="2.5" cx="5" cy={{add dest.y (div dest.height 2)}} />
{{/let}}
{{/if}}
{{/each}}
{{#each splitters as |item|}}
{{#each item.Splits as |item|}}
{{#let (dom-position (concat '#' item.NextNode)) as |dest|}}
<circle r="2.5" cx="5" cy={{add dest.y (div dest.height 2)}} />
{{/let}}
{{/each}}
{{/each}}
</svg>
<svg class="splitter-inlets" viewBox={{concat '0 0 10 ' height}}>
{{#each routes as |item|}}
{{#if (starts-with 'splitter:' item.NextNode) }}
{{#let (dom-position (concat '#' item.NextNode)) as |dest|}}
<circle r="2.5" cx="5" cy={{add dest.y (div dest.height 2)}} />
{{/let}}
{{/if}}
{{/each}}
</svg>
<div class={{concat 'tooltip' (if activeTooltip ' active' '')}} style={{{ concat 'position: absolute;top:' y 'px;left:' x 'px;'}}}>
<span role="tooltip">{{round tooltip decimals=2}}%</span>
</div>
{{/if}}

View File

@ -0,0 +1,65 @@
<div class="resolver-card">
<header onclick={{onclick}} id={{concat 'resolver:' item.ID}}>
<a name="">
<h3>{{item.Name}}</h3>
{{#if item.Failover}}
<dl class="failover">
<dt data-tooltip={{concat item.Failover.Type ' failover'}}>{{concat item.Failover.Type ' failover'}}</dt>
<dd>
<ol>
{{#each item.Failover.Targets as |item|}}
<li>
<span>{{item}}</span>
</li>
{{/each}}
</ol>
</dd>
</dl>
{{else if item.Redirect}}
<dl class="redirect">
<dt data-tooltip="Redirect">Redirect</dt>
<dd>
<ol>
<li>
<span>{{item.ID}}</span>
</li>
</ol>
</dd>
</dl>
{{/if}}
</a>
</header>
{{#if (gt item.Children.length 0)}}
<ul>
{{#each item.Children as |child|}}
<li onclick={{onclick}} id={{concat 'resolver:' child.ID}}>
<a name="">
{{#if child.Failover}}
<dl class="failover">
<dt data-tooltip={{concat child.Failover.Type ' failover'}}>{{concat child.Failover.Type ' failover'}}</dt>
<dd>
<ol>
{{#each child.Failover.Targets as |target|}}
<li>
<span>{{target}}</span>
</li>
{{/each}}
</ol>
</dd>
</dl>
{{else if item.Redirect}}
<dl class="redirect">
<dt data-tooltip="Redirect">Redirect</dt>
<dd>
{{child.ID}}
</dd>
</dl>
{{else}}
{{child.ServiceSubset}}
{{/if}}
</a>
</li>
{{/each}}
</ul>
{{/if}}
</div>

View File

@ -0,0 +1,55 @@
<a class="route-card" onclick={{onclick}} id={{item.ID}}>
<header class={{if (eq path.value '/') 'short'}}>
{{#if (gt item.Definition.Match.HTTP.Methods.length 0) }}
<ul class="match-methods">
{{#each item.Definition.Match.HTTP.Methods as |item|}}
<li>{{item}}</li>
{{/each}}
</ul>
{{/if}}
<dl>
<dt>
{{path.type}}
</dt>
<dd>
{{#if (not-eq path.type 'Default')}}
{{path.value}}
{{/if}}
</dd>
</dl>
</header>
{{#if (gt item.Definition.Match.HTTP.Header.length 0) }}
<section class="match-headers">
<header data-tooltip="Header">
<h4>Headers</h4>
</header>
<dl>
{{#each item.Definition.Match.HTTP.Header as |item|}}
<dt>
{{item.Name}}
</dt>
<dd>
{{route-match item}}
</dd>
{{/each}}
</dl>
</section>
{{/if}}
{{#if (gt item.Definition.Match.HTTP.QueryParam.length 0) }}
<section class="match-queryparams">
<header data-tooltip="Query Params">
<h4>Query Params</h4>
</header>
<dl>
{{#each item.Definition.Match.HTTP.QueryParam as |item|}}
<dt>
{{item.Name}}
</dt>
<dd>
{{route-match item}}
</dd>
{{/each}}
</dl>
</section>
{{/if}}
</a>

View File

@ -0,0 +1,5 @@
<a class="splitter-card" onclick={{onclick}} id={{item.ID}}>
<header>
<h3>{{item.Name}}</h3>
</header>
</a>

View File

@ -0,0 +1 @@
{{discovery-chain chain=chain.Chain}}

View File

@ -28,6 +28,7 @@
items=(compact
(array
'Instances'
'Routing'
'Tags'
)
)
@ -44,6 +45,7 @@
(compact
(array
(hash id=(slugify 'Instances') partial='dc/services/instances')
(hash id=(slugify 'Routing') partial='dc/services/routing')
(hash id=(slugify 'Tags') partial='dc/services/tags')
)
) as |panel|

View File

@ -0,0 +1,209 @@
import EventTarget from 'consul-ui/utils/dom/event-target/rsvp';
import { set } from '@ember/object';
const IntervalTickerGroup = class extends EventTarget {
constructor(rate = 1000 / 60) {
super();
this.setRate(rate);
}
tick() {
this.dispatchEvent({ type: 'tick', target: this });
}
setRate(rate) {
clearInterval(this._interval);
this._interval = setInterval(() => this.tick(), rate);
}
destroy() {
clearInterval(this._interval);
}
};
export const Ticker = class extends EventTarget {
static destroy() {
if (typeof Ticker.defaultTickerGroup !== 'undefined') {
Ticker.defaultTickerGroup.destroy();
delete Ticker.defaultTickerGroup;
}
}
constructor(obj) {
super();
this.setTickable(obj);
}
tick() {
this._tickable.tick();
}
setTickable(tickable) {
this._tickable = tickable;
// this.addEventListener(this._tickable);
if (typeof this._tickable.getTicker === 'undefined') {
this._tickable.getTicker = () => this;
}
this.tick = this._tickable.tick.bind(this._tickable);
}
getTickable() {
return this._tickable;
}
isAlive() {
return this._isAlive;
}
start() {
this._isAlive = true;
this.getTickerGroup().addEventListener('tick', this.tick);
this.dispatchEvent({ type: 'start', target: this });
}
stop() {
this._isAlive = false;
this.getTickerGroup().removeEventListener('tick', this.tick);
this.dispatchEvent({ type: 'stop', target: this });
}
activeCount() {
return this.getTickerGroup().activeCount();
}
setTickerGroup(group) {
this._group = group;
}
getTickerGroup() {
if (typeof this._group === 'undefined') {
if (typeof Ticker.defaultTickerGroup === 'undefined') {
Ticker.defaultTickerGroup = new TickerGroup();
}
this._group = Ticker.defaultTickerGroup;
}
return this._group;
}
};
const TimelineAbstract = class {
constructor() {
this._currentframe = 1;
this.setIncrement(1);
}
isAtStart() {
return this._currentframe <= 1;
}
isAtEnd() {
return this._currentframe >= this._totalframes;
}
addEventListener() {
return this.getTicker().addEventListener(...arguments);
}
removeEventListener() {
return this.getTicker().removeEventListener(...arguments);
}
stop() {
return this.gotoAndStop(this._currentframe);
}
play() {
return this.gotoAndPlay(this._currentframe);
}
start() {
return this.gotoAndPlay(this._currentframe);
}
gotoAndStop(frame) {
this._currentframe = frame;
const ticker = this.getTicker();
if (ticker.isAlive()) {
ticker.stop();
}
return this;
}
gotoAndPlay(frame) {
this._currentframe = frame;
const ticker = this.getTicker();
if (!ticker.isAlive()) {
ticker.start();
}
return this;
}
getTicker() {
if (typeof this._ticker === 'undefined') {
this._ticker = new Ticker(this);
}
return this._ticker;
}
setFrames(frames) {
this._totalframes = frames;
return this;
}
setIncrement(inc) {
this._increment = inc;
return this;
}
};
const Cubic = {
easeOut: function(t, b, c, d) {
t /= d;
t--;
return c * (t * t * t + 1) + b;
},
};
const TickerGroup = IntervalTickerGroup;
export const Tween = class extends TimelineAbstract {
static destroy() {
Ticker.destroy();
}
static to(start, finish, frames, method) {
Object.keys(finish).forEach(function(key) {
finish[key] -= start[key];
});
return new Tween(start, finish, frames, method).play();
}
constructor(obj, props, frames = 12, method = Cubic.easeOut) {
super();
this.setMethod(method);
this.setProps(props);
this.setTarget(obj);
this.setFrames(frames);
this.tick = this.forwards;
}
_process() {
Object.keys(this._props).forEach(key => {
const num = this._method(
this._currentframe,
this._initialstate[key],
this._props[key],
this._totalframes
);
// this._target[key] = num;
set(this._target, key, num);
});
}
forwards() {
if (this._currentframe <= this._totalframes) {
this._process();
this._currentframe += this._increment;
} else {
this._currentframe = this._totalframes;
this.getTicker().stop();
}
}
backwards() {
this._currentframe -= this._increment;
if (this._currentframe >= 0) {
this._process();
} else {
this.run = this.forwards;
this._currentframe = 1;
this.getTicker().stop();
}
}
gotoAndPlay() {
if (typeof this._initialstate === 'undefined') {
this._initialstate = {};
Object.keys(this._props).forEach(key => {
this._initialstate[key] = this._target[key];
});
}
return super.gotoAndPlay(...arguments);
}
setTarget(target) {
this._target = target;
}
getTarget(target) {
return this._target;
}
setProps(props) {
this._props = props;
return this;
}
setMethod(method) {
this._method = method;
}
};

View File

@ -75,7 +75,9 @@ module.exports = function(defaults) {
// TextEncoder/Decoder polyfill. See assets/index.html
app.import('node_modules/text-encoding/lib/encoding-indexes.js', {outputFile: 'assets/encoding-indexes.js'});
app.import('node_modules/text-encoding/lib/encoding.js', {outputFile: 'assets/encoding.js'});
// CSS.escape polyfill
app.import('node_modules/css.escape/css.escape.js', {outputFile: 'assets/css.escape.js'});
// JSON linting support. Possibly dynamically loaded via CodeMirror linting. See components/code-editor.js
app.import('node_modules/jsonlint/lib/jsonlint.js', {outputFile: 'assets/codemirror/mode/javascript/javascript.js'});

View File

@ -22,6 +22,9 @@ module.exports = ({ appName, environment, rootURL, config }) => `
appendScript('${rootURL}assets/encoding-indexes.js');
appendScript('${rootURL}assets/encoding.js');
}
if(!(window.CSS && window.CSS.escape)) {
appendScript('${rootURL}assets/css.escape.js');
}
</script>
<script src="${rootURL}assets/${appName}.js"></script>
<script>

View File

@ -54,6 +54,7 @@
"broccoli-asset-rev": "^3.0.0",
"chalk": "^2.4.2",
"clipboard": "^2.0.4",
"css.escape": "^1.5.1",
"ember-auto-import": "^1.4.0",
"ember-changeset-validations": "^2.1.0",
"ember-cli": "~3.12.0",
@ -102,6 +103,7 @@
"jsonlint": "^1.6.3",
"lint-staged": "^9.2.5",
"loader.js": "^4.7.0",
"ngraph.graph": "^18.0.3",
"node-sass": "^4.9.3",
"prettier": "^1.10.2",
"qunit-dom": "^0.9.0",

View File

@ -42,8 +42,8 @@ Feature: Page Navigation
Where:
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| Item | Model | URL | Endpoint | Back |
| service | services | /dc-1/services/service-0 | /v1/health/service/service-0?dc=dc-1 | /dc-1/services |
| node | nodes | /dc-1/nodes/node-0 | /v1/coordinate/nodes?dc=dc-1 | /dc-1/nodes |
| service | services | /dc-1/services/service-0 | /v1/discovery-chain/service-0?dc=dc-1 | /dc-1/services |
| node | nodes | /dc-1/nodes/node-0 | /v1/session/node/node-0?dc=dc-1 | /dc-1/nodes |
| kv | kvs | /dc-1/kv/0-key-value/edit | /v1/session/info/ee52203d-989f-4f7a-ab5a-2bef004164ca?dc=dc-1 | /dc-1/kv |
# | acl | acls | /dc-1/acls/anonymous | /v1/acl/info/anonymous?dc=dc-1 | /dc-1/acls |
| intention | intentions | /dc-1/intentions/ee52203d-989f-4f7a-ab5a-2bef004164ca | /v1/internal/ui/services?dc=dc-1&ns=* | /dc-1/intentions |

View File

@ -0,0 +1,27 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Integration | Adapter | discovery-chain', 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:discovery-chain');
const client = this.owner.lookup('service:client/http');
const expected = `GET /v1/discovery-chain/${id}?dc=${dc}`;
const actual = adapter.requestForQueryRecord(client.url, {
dc: dc,
id: id,
});
assert.equal(actual, expected);
});
test("requestForQueryRecord throws if you don't specify an id", function(assert) {
const adapter = this.owner.lookup('adapter:discovery-chain');
const client = this.owner.lookup('service:client/http');
assert.throws(function() {
adapter.requestForQueryRecord(client.url, {
dc: dc,
});
});
});
});

View File

@ -0,0 +1,24 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | discovery-chain', function(hooks) {
setupRenderingTest(hooks);
skip('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`{{discovery-chain}}`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
{{#discovery-chain}}{{/discovery-chain}}
`);
assert.equal(this.element.textContent.trim(), '');
});
});

View File

@ -0,0 +1,26 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | resolver-card', function(hooks) {
setupRenderingTest(hooks);
skip('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`{{resolver-card}}`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
{{#resolver-card}}
template block text
{{/resolver-card}}
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});

View File

@ -0,0 +1,26 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | route-card', function(hooks) {
setupRenderingTest(hooks);
skip('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`{{route-card}}`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
{{#route-card}}
template block text
{{/route-card}}
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});

View File

@ -0,0 +1,26 @@
import { module, skip } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | splitter-card', function(hooks) {
setupRenderingTest(hooks);
skip('it renders', async function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`{{splitter-card}}`);
assert.equal(this.element.textContent.trim(), '');
// Template block usage:
await render(hbs`
{{#splitter-card}}
template block text
{{/splitter-card}}
`);
assert.equal(this.element.textContent.trim(), 'template block text');
});
});

View File

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

View File

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

View File

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

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 'htmlbars-inline-precompile';
module('Integration | Helper | tween-to', function(hooks) {
setupRenderingTest(hooks);
// Replace this with your real tests.
test('it renders', async function(assert) {
this.set('inputValue', '1234');
await render(hbs`{{tween-to inputValue}}`);
assert.equal(this.element.textContent.trim(), '1234');
});
});

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 | discovery-chain', function(hooks) {
setupTest(hooks);
test('respondForQueryRecord returns the correct data for item endpoint', function(assert) {
const serializer = this.owner.lookup('serializer:discovery-chain');
const dc = 'dc-1';
const id = 'slug';
const request = {
url: `/v1/discovery-chain/${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,41 @@
import { moduleFor, test } from 'ember-qunit';
import repo from 'consul-ui/tests/helpers/repo';
moduleFor('service:repository/discovery-chain', 'Integration | Repository | discovery-chain', {
// 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/discovery-chain/${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: {
cursor: undefined,
},
},
payload
);
});
assert.equal(actual.Datacenter, result.Datacenter);
assert.equal(actual.uid, result.uid);
}
);
});

View File

@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Adapter | discovery-chain', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let adapter = this.owner.lookup('adapter:discovery-chain');
assert.ok(adapter);
});
});

View File

@ -0,0 +1,13 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Model | discovery-chain', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let model = store.createRecord('discovery-chain', {});
assert.ok(model);
});
});

View File

@ -0,0 +1,23 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | discovery-chain', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let serializer = store.serializerFor('discovery-chain');
assert.ok(serializer);
});
test('it serializes records', function(assert) {
let store = this.owner.lookup('service:store');
let record = store.createRecord('discovery-chain', {});
let serializedRecord = record.serialize();
assert.ok(serializedRecord);
});
});

View File

@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Service | data-structs', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let service = this.owner.lookup('service:data-structs');
assert.ok(service);
});
});

View File

@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Repository | discovery-chain', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let repo = this.owner.lookup('service:repository/discovery-chain');
assert.ok(repo);
});
});

View File

@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Service | Ticker', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let service = this.owner.lookup('service:ticker');
assert.ok(service);
});
});

View File

@ -0,0 +1,10 @@
import tickerIndex from 'consul-ui/utils/ticker/index';
import { module, skip } from 'qunit';
module('Unit | Utility | ticker/index', function() {
// Replace this with your real tests.
skip('it works', function(assert) {
let result = tickerIndex();
assert.ok(result);
});
});

View File

@ -3955,6 +3955,11 @@ crypto-random-string@^1.0.0:
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
css.escape@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=
cssom@0.3.x, cssom@^0.3.4:
version "0.3.8"
resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a"
@ -8417,6 +8422,18 @@ neo-async@^2.5.0, neo-async@^2.6.0:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c"
integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==
ngraph.events@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/ngraph.events/-/ngraph.events-1.0.0.tgz#260b638f1d8b1394a10ce1dda9e5d9d9c85001b1"
integrity sha512-Z7wyywdw8IKjOW0bDiOG4FUqX5fwqW7SDMO1huemDXho7Qy1b02RoBkPS43KLIZU2wrW2orju99k8wIr+xXvVA==
ngraph.graph@^18.0.3:
version "18.0.3"
resolved "https://registry.yarnpkg.com/ngraph.graph/-/ngraph.graph-18.0.3.tgz#5cab1502be4b3d5ac5470083596a070bd85ec8ba"
integrity sha512-IjGOWvYSUi1Oj3V1JrhhLGPY/rqvbvhUOa5tgOzIQo/LxO4wcBOSjZGZWeTTBUNLPt6R5hpkWKrUBaqPxgY4Ew==
dependencies:
ngraph.events "1.0.0"
nice-try@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"