diff --git a/ui-v2/app/adapters/discovery-chain.js b/ui-v2/app/adapters/discovery-chain.js new file mode 100644 index 0000000000..81f6f6f052 --- /dev/null +++ b/ui-v2/app/adapters/discovery-chain.js @@ -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 }} + `; + }, +}); diff --git a/ui-v2/app/components/discovery-chain.js b/ui-v2/app/components/discovery-chain.js new file mode 100644 index 0000000000..47e6d00616 --- /dev/null +++ b/ui-v2/app/components/discovery-chain.js @@ -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 + // 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); + } + }, + }, +}); diff --git a/ui-v2/app/components/resolver-card.js b/ui-v2/app/components/resolver-card.js new file mode 100644 index 0000000000..4798652642 --- /dev/null +++ b/ui-v2/app/components/resolver-card.js @@ -0,0 +1,5 @@ +import Component from '@ember/component'; + +export default Component.extend({ + tagName: '', +}); diff --git a/ui-v2/app/components/route-card.js b/ui-v2/app/components/route-card.js new file mode 100644 index 0000000000..ce9b3547d2 --- /dev/null +++ b/ui-v2/app/components/route-card.js @@ -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: '/', + } + ); + }), +}); diff --git a/ui-v2/app/components/splitter-card.js b/ui-v2/app/components/splitter-card.js new file mode 100644 index 0000000000..5570647734 --- /dev/null +++ b/ui-v2/app/components/splitter-card.js @@ -0,0 +1,3 @@ +import Component from '@ember/component'; + +export default Component.extend({}); diff --git a/ui-v2/app/controllers/dc/services/show.js b/ui-v2/app/controllers/dc/services/show.js index 3270222342..86684593dd 100644 --- a/ui-v2/app/controllers/dc/services/show.js +++ b/ui-v2/app/controllers/dc/services/show.js @@ -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) { diff --git a/ui-v2/app/helpers/css-var.js b/ui-v2/app/helpers/css-var.js index 77f31ecd97..480abba38f 100644 --- a/ui-v2/app/helpers/css-var.js +++ b/ui-v2/app/helpers/css-var.js @@ -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,')`, '--terraform-color-svg': `url('data:image/svg+xml;charset=UTF-8,')`, '--nomad-color-svg': `url('data:image/svg+xml;charset=UTF-8,')`, diff --git a/ui-v2/app/helpers/dom-position.js b/ui-v2/app/helpers/dom-position.js new file mode 100644 index 0000000000..dfe69c4c73 --- /dev/null +++ b/ui-v2/app/helpers/dom-position.js @@ -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, + } + ); + }, +}); diff --git a/ui-v2/app/helpers/route-match.js b/ui-v2/app/helpers/route-match.js new file mode 100644 index 0000000000..83895a7fd2 --- /dev/null +++ b/ui-v2/app/helpers/route-match.js @@ -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 ''; +}); diff --git a/ui-v2/app/helpers/svg-curve.js b/ui-v2/app/helpers/svg-curve.js new file mode 100644 index 0000000000..4bdaa68acf --- /dev/null +++ b/ui-v2/app/helpers/svg-curve.js @@ -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)}`; +}); diff --git a/ui-v2/app/helpers/tween-to.js b/ui-v2/app/helpers/tween-to.js new file mode 100644 index 0000000000..37c441f751 --- /dev/null +++ b/ui-v2/app/helpers/tween-to.js @@ -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); + }, +}); diff --git a/ui-v2/app/instance-initializers/event-source.js b/ui-v2/app/instance-initializers/event-source.js index 7a5e3eddab..07b7f37b33 100644 --- a/ui-v2/app/instance-initializers/event-source.js +++ b/ui-v2/app/instance-initializers/event-source.js @@ -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', }, }, { diff --git a/ui-v2/app/models/discovery-chain.js b/ui-v2/app/models/discovery-chain.js new file mode 100644 index 0000000000..e5c15a571d --- /dev/null +++ b/ui-v2/app/models/discovery-chain.js @@ -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(), +}); diff --git a/ui-v2/app/routes/dc/services/show.js b/ui-v2/app/routes/dc/services/show.js index 33907a4acf..f732832d8b 100644 --- a/ui-v2/app/routes/dc/services/show.js +++ b/ui-v2/app/routes/dc/services/show.js @@ -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, }); diff --git a/ui-v2/app/serializers/discovery-chain.js b/ui-v2/app/serializers/discovery-chain.js new file mode 100644 index 0000000000..fc144194f3 --- /dev/null +++ b/ui-v2/app/serializers/discovery-chain.js @@ -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); + }, +}); diff --git a/ui-v2/app/services/data-structs.js b/ui-v2/app/services/data-structs.js new file mode 100644 index 0000000000..7fa27f1523 --- /dev/null +++ b/ui-v2/app/services/data-structs.js @@ -0,0 +1,9 @@ +import Service from '@ember/service'; + +import createGraph from 'ngraph.graph'; + +export default Service.extend({ + graph: function() { + return createGraph(); + }, +}); diff --git a/ui-v2/app/services/dom.js b/ui-v2/app/services/dom.js index 1af967f1c8..0a023c7672 100644 --- a/ui-v2/app/services/dom.js +++ b/ui-v2/app/services/dom.js @@ -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 + }, }); diff --git a/ui-v2/app/services/repository/discovery-chain.js b/ui-v2/app/services/repository/discovery-chain.js new file mode 100644 index 0000000000..628eb07d49 --- /dev/null +++ b/ui-v2/app/services/repository/discovery-chain.js @@ -0,0 +1,8 @@ +import RepositoryService from 'consul-ui/services/repository'; + +const modelName = 'discovery-chain'; +export default RepositoryService.extend({ + getModelName: function() { + return modelName; + }, +}); diff --git a/ui-v2/app/services/ticker.js b/ui-v2/app/services/ticker.js new file mode 100644 index 0000000000..c2cb59282d --- /dev/null +++ b/ui-v2/app/services/ticker.js @@ -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(); + }, +}); diff --git a/ui-v2/app/styles/base/icons/base-variables.scss b/ui-v2/app/styles/base/icons/base-variables.scss index 02b92a8dda..baf188481a 100644 --- a/ui-v2/app/styles/base/icons/base-variables.scss +++ b/ui-v2/app/styles/base/icons/base-variables.scss @@ -28,6 +28,7 @@ $chevron-up-svg: url('data:image/svg+xml;charset=UTF-8,'); $clock-fill-svg: url('data:image/svg+xml;charset=UTF-8,'); $clock-outline-svg: url('data:image/svg+xml;charset=UTF-8,'); +$cloud-cross-svg: url('data:image/svg+xml;charset=UTF-8,'); $code-svg: url('data:image/svg+xml;charset=UTF-8,'); $consul-logo-color-svg: url('data:image/svg+xml;charset=UTF-8,'); $copy-action-svg: url('data:image/svg+xml;charset=UTF-8,'); @@ -81,6 +82,7 @@ $plus-circle-outline-svg: url('data:image/svg+xml;charset=UTF-8,'); $plus-square-fill-svg: url('data:image/svg+xml;charset=UTF-8,'); $queue-svg: url('data:image/svg+xml;charset=UTF-8,'); +$redirect-svg: url('data:image/svg+xml;charset=UTF-8,'); $refresh-svg: url('data:image/svg+xml;charset=UTF-8,'); $run-svg: url('data:image/svg+xml;charset=UTF-8,'); $search-color-svg: url('data:image/svg+xml;charset=UTF-8,'); diff --git a/ui-v2/app/styles/base/icons/icon-placeholders.scss b/ui-v2/app/styles/base/icons/icon-placeholders.scss index db26da71dd..532ad17acf 100644 --- a/ui-v2/app/styles/base/icons/icon-placeholders.scss +++ b/ui-v2/app/styles/base/icons/icon-placeholders.scss @@ -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; diff --git a/ui-v2/app/styles/components/card/index.scss b/ui-v2/app/styles/components/card/index.scss new file mode 100644 index 0000000000..bc18252196 --- /dev/null +++ b/ui-v2/app/styles/components/card/index.scss @@ -0,0 +1,2 @@ +@import './skin'; +@import './layout'; diff --git a/ui-v2/app/styles/components/card/layout.scss b/ui-v2/app/styles/components/card/layout.scss new file mode 100644 index 0000000000..412c0b2aa0 --- /dev/null +++ b/ui-v2/app/styles/components/card/layout.scss @@ -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; +} diff --git a/ui-v2/app/styles/components/card/skin.scss b/ui-v2/app/styles/components/card/skin.scss new file mode 100644 index 0000000000..f7cee4f3ee --- /dev/null +++ b/ui-v2/app/styles/components/card/skin.scss @@ -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); +} diff --git a/ui-v2/app/styles/components/discovery-chain.scss b/ui-v2/app/styles/components/discovery-chain.scss new file mode 100644 index 0000000000..972fb8720a --- /dev/null +++ b/ui-v2/app/styles/components/discovery-chain.scss @@ -0,0 +1,2 @@ +@import './card/index'; +@import './discovery-chain/index'; diff --git a/ui-v2/app/styles/components/discovery-chain/index.scss b/ui-v2/app/styles/components/discovery-chain/index.scss new file mode 100644 index 0000000000..bc18252196 --- /dev/null +++ b/ui-v2/app/styles/components/discovery-chain/index.scss @@ -0,0 +1,2 @@ +@import './skin'; +@import './layout'; diff --git a/ui-v2/app/styles/components/discovery-chain/layout.scss b/ui-v2/app/styles/components/discovery-chain/layout.scss new file mode 100644 index 0000000000..36b9a61087 --- /dev/null +++ b/ui-v2/app/styles/components/discovery-chain/layout.scss @@ -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); +} diff --git a/ui-v2/app/styles/components/discovery-chain/skin.scss b/ui-v2/app/styles/components/discovery-chain/skin.scss new file mode 100644 index 0000000000..b4166bc012 --- /dev/null +++ b/ui-v2/app/styles/components/discovery-chain/skin.scss @@ -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; +} diff --git a/ui-v2/app/styles/components/index.scss b/ui-v2/app/styles/components/index.scss index e0ee99db2b..6783a7db5e 100644 --- a/ui-v2/app/styles/components/index.scss +++ b/ui-v2/app/styles/components/index.scss @@ -32,3 +32,4 @@ @import './notice'; @import './tooltip'; @import './sort-control'; +@import './discovery-chain'; diff --git a/ui-v2/app/styles/core/typography.scss b/ui-v2/app/styles/core/typography.scss index a72194cdaa..c82a8133c3 100644 --- a/ui-v2/app/styles/core/typography.scss +++ b/ui-v2/app/styles/core/typography.scss @@ -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; } diff --git a/ui-v2/app/templates/components/discovery-chain.hbs b/ui-v2/app/templates/components/discovery-chain.hbs new file mode 100644 index 0000000000..9a8f4f3597 --- /dev/null +++ b/ui-v2/app/templates/components/discovery-chain.hbs @@ -0,0 +1,141 @@ +{{#if isDisplayed }} + +
+
+

+ {{chain.ServiceName}} Router + + Use routers to intercept traffic using L7 criteria such as path prefixes or http headers. + +

+
+
+ {{#each routes as |item|}} + {{route-card item=item onclick=(action 'click')}} + {{/each}} +
+
+
+
+

+ Splitters + + Splitters are configured to split incoming requests across different services or subsets of a single service. + +

+
+
+ {{#each (sort-by 'Name' splitters) as |item|}} + {{splitter-card item=item onclick=(action 'click')}} + {{/each}} +
+
+
+
+

+ Resolvers + + Resolvers are used to define which instances of a service should satisfy discovery requests. + +

+
+
+ {{#each (sort-by 'Name' resolvers) as |item|}} + {{resolver-card item=item onclick=(action 'click')}} + {{/each}} +
+
+ + {{#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|}} + ' 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|}} + ' 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}} + + + {{#each routes as |item|}} + {{#if (starts-with 'resolver:' item.NextNode) }} + {{#let (dom-position (concat '#' item.NextNode)) as |dest|}} + + {{/let}} + {{/if}} + {{/each}} + {{#each splitters as |item|}} + {{#each item.Splits as |item|}} + {{#let (dom-position (concat '#' item.NextNode)) as |dest|}} + + {{/let}} + {{/each}} + {{/each}} + + + {{#each routes as |item|}} + {{#if (starts-with 'splitter:' item.NextNode) }} + {{#let (dom-position (concat '#' item.NextNode)) as |dest|}} + + {{/let}} + {{/if}} + {{/each}} + +
+ {{round tooltip decimals=2}}% +
+{{/if}} diff --git a/ui-v2/app/templates/components/resolver-card.hbs b/ui-v2/app/templates/components/resolver-card.hbs new file mode 100644 index 0000000000..b252c5bb6a --- /dev/null +++ b/ui-v2/app/templates/components/resolver-card.hbs @@ -0,0 +1,65 @@ +
+
+ +

{{item.Name}}

+{{#if item.Failover}} +
+
{{concat item.Failover.Type ' failover'}}
+
+
    + {{#each item.Failover.Targets as |item|}} +
  1. + {{item}} +
  2. + {{/each}} +
+
+
+{{else if item.Redirect}} +
+
Redirect
+
+
    +
  1. + {{item.ID}} +
  2. +
+
+
+{{/if}} +
+
+{{#if (gt item.Children.length 0)}} + +{{/if}} +
diff --git a/ui-v2/app/templates/components/route-card.hbs b/ui-v2/app/templates/components/route-card.hbs new file mode 100644 index 0000000000..5bb44fa0cd --- /dev/null +++ b/ui-v2/app/templates/components/route-card.hbs @@ -0,0 +1,55 @@ + +
+{{#if (gt item.Definition.Match.HTTP.Methods.length 0) }} +
    + {{#each item.Definition.Match.HTTP.Methods as |item|}} +
  • {{item}}
  • + {{/each}} +
+{{/if}} +
+
+ {{path.type}} +
+
+ {{#if (not-eq path.type 'Default')}} + {{path.value}} + {{/if}} +
+
+
+{{#if (gt item.Definition.Match.HTTP.Header.length 0) }} +
+
+

Headers

+
+
+ {{#each item.Definition.Match.HTTP.Header as |item|}} +
+ {{item.Name}} +
+
+ {{route-match item}} +
+ {{/each}} +
+
+{{/if}} +{{#if (gt item.Definition.Match.HTTP.QueryParam.length 0) }} +
+
+

Query Params

+
+
+ {{#each item.Definition.Match.HTTP.QueryParam as |item|}} +
+ {{item.Name}} +
+
+ {{route-match item}} +
+ {{/each}} +
+
+{{/if}} +
diff --git a/ui-v2/app/templates/components/splitter-card.hbs b/ui-v2/app/templates/components/splitter-card.hbs new file mode 100644 index 0000000000..034441c305 --- /dev/null +++ b/ui-v2/app/templates/components/splitter-card.hbs @@ -0,0 +1,5 @@ + +
+

{{item.Name}}

+
+
diff --git a/ui-v2/app/templates/dc/services/-routing.hbs b/ui-v2/app/templates/dc/services/-routing.hbs new file mode 100644 index 0000000000..717f83de2d --- /dev/null +++ b/ui-v2/app/templates/dc/services/-routing.hbs @@ -0,0 +1 @@ +{{discovery-chain chain=chain.Chain}} diff --git a/ui-v2/app/templates/dc/services/show.hbs b/ui-v2/app/templates/dc/services/show.hbs index af711eb1fc..9ba563c1ec 100644 --- a/ui-v2/app/templates/dc/services/show.hbs +++ b/ui-v2/app/templates/dc/services/show.hbs @@ -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| diff --git a/ui-v2/app/utils/ticker/index.js b/ui-v2/app/utils/ticker/index.js new file mode 100644 index 0000000000..688ec2cae3 --- /dev/null +++ b/ui-v2/app/utils/ticker/index.js @@ -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; + } +}; diff --git a/ui-v2/ember-cli-build.js b/ui-v2/ember-cli-build.js index f01d3bb44a..05f076649e 100644 --- a/ui-v2/ember-cli-build.js +++ b/ui-v2/ember-cli-build.js @@ -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'}); diff --git a/ui-v2/lib/startup/templates/body.html.js b/ui-v2/lib/startup/templates/body.html.js index 36cc1b63cb..7ada8f8403 100644 --- a/ui-v2/lib/startup/templates/body.html.js +++ b/ui-v2/lib/startup/templates/body.html.js @@ -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'); + }