diff --git a/ui-v2/app/components/templated-anchor.js b/ui-v2/app/components/templated-anchor.js new file mode 100644 index 000000000000..9336ebf50d6a --- /dev/null +++ b/ui-v2/app/components/templated-anchor.js @@ -0,0 +1,73 @@ +import Component from '@ember/component'; +import { get, set, computed } from '@ember/object'; + +const createWeak = function(wm = new WeakMap()) { + return { + get: function(ref, prop) { + let map = wm.get(ref); + if (map) { + return map[prop]; + } + }, + set: function(ref, prop, value) { + let map = wm.get(ref); + if (typeof map === 'undefined') { + map = {}; + wm.set(ref, map); + } + map[prop] = value; + return map[prop]; + }, + }; +}; +const weak = createWeak(); +// Covers alpha-capitalized dot separated API keys such as +// `{{Name}}`, `{{Service.Name}}` etc. but not `{{}}` +const templateRe = /{{([A-Za-z.0-9_-]+)}}/g; +export default Component.extend({ + tagName: 'a', + attributeBindings: ['href', 'rel', 'target'], + rel: computed({ + get: function(prop) { + return weak.get(this, prop); + }, + set: function(prop, value) { + switch (value) { + case 'external': + value = `${value} noopener noreferrer`; + set(this, 'target', '_blank'); + break; + } + return weak.set(this, prop, value); + }, + }), + vars: computed({ + get: function(prop) { + return weak.get(this, prop); + }, + set: function(prop, value) { + weak.set(this, prop, value); + set(this, 'href', weak.get(this, 'template')); + }, + }), + href: computed({ + get: function(prop) { + return weak.get(this, prop); + }, + set: function(prop, value) { + weak.set(this, 'template', value); + const vars = weak.get(this, 'vars'); + if (typeof vars !== 'undefined' && typeof value !== 'undefined') { + value = value.replace(templateRe, function(match, group) { + try { + return get(vars, group) || ''; + } catch (e) { + return ''; + } + }); + return weak.set(this, prop, value); + } + return ''; + }, + }), +}); diff --git a/ui-v2/app/controllers/settings.js b/ui-v2/app/controllers/settings.js index d70fc372fd01..91771e0f94f4 100644 --- a/ui-v2/app/controllers/settings.js +++ b/ui-v2/app/controllers/settings.js @@ -6,6 +6,13 @@ export default Controller.extend({ repo: service('settings'), dom: service('dom'), actions: { + key: function(e) { + switch (true) { + case e.keyCode === 13: + // disable ENTER + e.preventDefault(); + } + }, change: function(e, value, item) { const event = get(this, 'dom').normalizeEvent(e, value); // TODO: Switch to using forms like the rest of the app @@ -23,6 +30,13 @@ export default Controller.extend({ set(this, 'item.client.blocking', !blocking); this.send('update', get(this, 'item')); break; + case 'urls[service]': + if (typeof get(this, 'item.urls') === 'undefined') { + set(this, 'item.urls', {}); + } + set(this, 'item.urls.service', target.value); + this.send('update', get(this, 'item')); + break; } }, }, diff --git a/ui-v2/app/routes/dc/services/show.js b/ui-v2/app/routes/dc/services/show.js index 670c60766937..361b0e15df9f 100644 --- a/ui-v2/app/routes/dc/services/show.js +++ b/ui-v2/app/routes/dc/services/show.js @@ -5,6 +5,7 @@ import { get } from '@ember/object'; export default Route.extend({ repo: service('repository/service'), + settings: service('settings'), queryParams: { s: { as: 'filter', @@ -13,8 +14,10 @@ export default Route.extend({ }, model: function(params) { const repo = get(this, 'repo'); + const settings = get(this, 'settings'); return hash({ item: repo.findBySlug(params.name, this.modelFor('dc').dc.Name), + urls: settings.findBySlug('urls'), }); }, setupController: function(controller, model) { diff --git a/ui-v2/app/styles/components/anchors.scss b/ui-v2/app/styles/components/anchors.scss index af16dbd1b63b..d9dfa80f9c5b 100644 --- a/ui-v2/app/styles/components/anchors.scss +++ b/ui-v2/app/styles/components/anchors.scss @@ -2,6 +2,9 @@ %main-content a { color: $gray-900; } +a[rel*='external'] { + @extend %with-exit; +} %main-content label a[rel*='help'] { color: $gray-400; } diff --git a/ui-v2/app/styles/components/app-view.scss b/ui-v2/app/styles/components/app-view.scss index 11f634561a7e..668f0143d464 100644 --- a/ui-v2/app/styles/components/app-view.scss +++ b/ui-v2/app/styles/components/app-view.scss @@ -13,7 +13,7 @@ main { } @media #{$--lt-spacious-page-header} { %app-view header .actions { - margin-top: 5px; + margin-top: 9px; } } // TODO: This should be its own component diff --git a/ui-v2/app/styles/components/app-view/layout.scss b/ui-v2/app/styles/components/app-view/layout.scss index 4c1fa44032ef..4bb9e4247e71 100644 --- a/ui-v2/app/styles/components/app-view/layout.scss +++ b/ui-v2/app/styles/components/app-view/layout.scss @@ -9,6 +9,7 @@ float: right; display: flex; align-items: flex-start; + margin-top: 9px; } %app-view header dl { float: left; @@ -28,12 +29,7 @@ } %app-view h2 { padding-bottom: 0.2em; - margin-bottom: 1.1em; -} -%app-view fieldset h2, -%app-view fieldset p { - padding-bottom: 0; - margin-bottom: 0; + margin-bottom: 0.2em; } %app-view header .actions > *:not(:last-child) { margin-right: 12px; @@ -64,3 +60,8 @@ min-height: 1em; margin-bottom: 0.4em; } +// TODO: Think about an %app-form or similar +%app-content fieldset { + padding-bottom: 0.3em; + margin-bottom: 2em; +} diff --git a/ui-v2/app/styles/components/app-view/skin.scss b/ui-v2/app/styles/components/app-view/skin.scss index 607135363d5c..431c380d43a3 100644 --- a/ui-v2/app/styles/components/app-view/skin.scss +++ b/ui-v2/app/styles/components/app-view/skin.scss @@ -1,4 +1,5 @@ -%app-view h2 { +%app-view h2, +%app-view fieldset { border-bottom: $decor-border-200; } %app-view fieldset h2 { @@ -16,7 +17,8 @@ } %app-view header > div > div:last-child, %app-view header h1, -%app-view h2 { +%app-view h2, +%app-view fieldset { border-color: $gray-200; } // We know that any sibling navs might have a top border diff --git a/ui-v2/app/styles/components/icons/index.scss b/ui-v2/app/styles/components/icons/index.scss index 5f3dfdb460f7..7ff829e43ab8 100644 --- a/ui-v2/app/styles/components/icons/index.scss +++ b/ui-v2/app/styles/components/icons/index.scss @@ -149,6 +149,15 @@ height: 0.05em; transform: rotate(45deg); } +%with-exit::after { + @extend %pseudo-icon-bg-img; + top: 3px; + right: -8px; + background-image: $exit-svg; + background-color: $color-transparent; + width: 16px; + height: 16px; +} /*TODO: All chevrons need merging */ %with-chevron-down::before { @extend %pseudo-icon-bg-img; diff --git a/ui-v2/app/styles/components/notice/layout.scss b/ui-v2/app/styles/components/notice/layout.scss index 2e101a21f1db..f2e9a9a77706 100644 --- a/ui-v2/app/styles/components/notice/layout.scss +++ b/ui-v2/app/styles/components/notice/layout.scss @@ -2,6 +2,7 @@ position: relative; padding: 1em; padding-left: 45px; + margin-bottom: 1em; } %notice::before { position: absolute; diff --git a/ui-v2/app/styles/core/typography.scss b/ui-v2/app/styles/core/typography.scss index 134d7090943a..8b5eeeed2c83 100644 --- a/ui-v2/app/styles/core/typography.scss +++ b/ui-v2/app/styles/core/typography.scss @@ -1,5 +1,8 @@ -main p, -%modal-window p { +%button { + font-family: $typo-family-sans; +} +main p:not(:last-child), +%modal-window p:not(:last-child) { margin-bottom: 1em; } %button, @@ -8,6 +11,7 @@ main p, %form-element [type='password'] { line-height: 1.5; } +h3, %radio-group label { line-height: 1.25; } @@ -32,6 +36,7 @@ h1, font-weight: $typo-weight-bold; } h2, +h3, fieldset > header, caption, %header-nav, @@ -77,18 +82,22 @@ td strong, %footer > * { font-size: inherit; } + h1 { font-size: $typo-header-100; } -h2, +h2 { + font-size: $typo-header-200; +} +h3 { + font-size: $typo-header-300; +} %healthcheck-info dt, %header-drop-nav .is-active, %app-view h1 em { font-size: $typo-size-500; } body, -fieldset h2, -fieldset > header, pre code, input, textarea, diff --git a/ui-v2/app/styles/routes/dc/acls/index.scss b/ui-v2/app/styles/routes/dc/acls/index.scss index 99270f734e8a..849ed0337e56 100644 --- a/ui-v2/app/styles/routes/dc/acls/index.scss +++ b/ui-v2/app/styles/routes/dc/acls/index.scss @@ -10,7 +10,7 @@ td a.is-management::after { .template-policy.template-list main header .actions, .template-token.template-list main header .actions { position: relative; - top: 50px; + top: 45px; } } diff --git a/ui-v2/app/templates/components/templated-anchor.hbs b/ui-v2/app/templates/components/templated-anchor.hbs new file mode 100644 index 000000000000..fb5c4b157d1c --- /dev/null +++ b/ui-v2/app/templates/components/templated-anchor.hbs @@ -0,0 +1 @@ +{{yield}} \ No newline at end of file diff --git a/ui-v2/app/templates/dc/nodes/show.hbs b/ui-v2/app/templates/dc/nodes/show.hbs index 3a4d135ffe6d..627de3e5bde0 100644 --- a/ui-v2/app/templates/dc/nodes/show.hbs +++ b/ui-v2/app/templates/dc/nodes/show.hbs @@ -26,23 +26,23 @@ }} {{/block-slot}} {{#block-slot 'actions'}} - {{#feedback-dialog type='inline'}} - {{#block-slot 'action' as |success error|}} - {{#copy-button success=(action success) error=(action error) clipboardText=item.Address title='copy IP address to clipboard'}} - {{item.Address}} - {{/copy-button}} - {{/block-slot}} - {{#block-slot 'success' as |transition|}} -

- Copied IP Address! -

- {{/block-slot}} - {{#block-slot 'error' as |transition|}} -

- Sorry, something went wrong! -

- {{/block-slot}} - {{/feedback-dialog}} + {{#feedback-dialog type='inline'}} + {{#block-slot 'action' as |success error|}} + {{#copy-button success=(action success) error=(action error) clipboardText=item.Address title='copy IP address to clipboard'}} + {{item.Address}} + {{/copy-button}} + {{/block-slot}} + {{#block-slot 'success' as |transition|}} +

+ Copied IP Address! +

+ {{/block-slot}} + {{#block-slot 'error' as |transition|}} +

+ Sorry, something went wrong! +

+ {{/block-slot}} + {{/feedback-dialog}} {{/block-slot}} {{#block-slot 'content'}} {{#each diff --git a/ui-v2/app/templates/dc/services/show.hbs b/ui-v2/app/templates/dc/services/show.hbs index a648fd833710..452017b2a583 100644 --- a/ui-v2/app/templates/dc/services/show.hbs +++ b/ui-v2/app/templates/dc/services/show.hbs @@ -32,6 +32,11 @@ selected=selectedTab }} {{/block-slot}} + {{#block-slot 'actions'}} + {{#if urls.service}} + {{#templated-anchor href=urls.service vars=(hash Service=(hash Name=item.Service.Service)) rel="external"}}Open Dashboard{{/templated-anchor}} + {{/if}} + {{/block-slot}} {{#block-slot 'content'}} {{#each (compact diff --git a/ui-v2/app/templates/settings.hbs b/ui-v2/app/templates/settings.hbs index aaf9565340bf..4a4874cc211f 100644 --- a/ui-v2/app/templates/settings.hbs +++ b/ui-v2/app/templates/settings.hbs @@ -1,26 +1,40 @@ {{#hashicorp-consul id="wrapper" dcs=dcs dc=dc}} - {{#app-view class="settings show"}} - {{#block-slot 'header'}} -

- Settings -

- {{/block-slot}} - {{#block-slot 'content'}} -

- These settings are specific to the Consul web UI. They are saved to local storage and persist through browser usage and visits. -

-
-
-

Blocking Queries

-

Automatically get updated catalog information without refreshing the page. Any changes made to services and nodes would be reflected in real time.

-
- -
-
-
- {{/block-slot}} - {{/app-view}} + {{#app-view class="settings show"}} + {{#block-slot 'header'}} +

+ Settings +

+ {{/block-slot}} + {{#block-slot 'content'}} +
+

Local Storage

+

+ These settings are immediately saved to local storage and persisted through browser usage. +

+
+
+
+

Dashboard Links

+

+ Add a link to the service detail page in the UI to get quick access to a service-wide metrics dashboard. Enter the dashboard URL into the field below. You can use the placeholder {{Service.Name}} which will be replaced with the name of the service currently being viewed. +

+ +
+
+

Blocking Queries

+

Keep catalog info up-to-date without refreshing the page. Any changes made to services and nodes would be reflected in real time.

+
+ +
+
+
+ {{/block-slot}} + {{/app-view}} {{/hashicorp-consul}} \ No newline at end of file diff --git a/ui-v2/tests/integration/components/templated-anchor-test.js b/ui-v2/tests/integration/components/templated-anchor-test.js new file mode 100644 index 000000000000..17b9d74ac2a1 --- /dev/null +++ b/ui-v2/tests/integration/components/templated-anchor-test.js @@ -0,0 +1,98 @@ +import { moduleForComponent, test } from 'ember-qunit'; +import hbs from 'htmlbars-inline-precompile'; + +moduleForComponent('templated-anchor', 'Integration | Component | templated anchor', { + integration: true, +}); + +test('it renders', function(assert) { + [ + { + href: 'http://localhost/?={{Name}}/{{ID}}', + vars: { + Name: 'name', + ID: 'id', + }, + result: 'http://localhost/?=name/id', + }, + { + href: 'http://localhost/?={{Name}}/{{ID}}', + vars: { + Name: '{{Name}}', + ID: '{{ID}}', + }, + result: 'http://localhost/?={{Name}}/{{ID}}', + }, + { + href: 'http://localhost/?={{deep.Name}}/{{deep.ID}}', + vars: { + deep: { + Name: '{{Name}}', + ID: '{{ID}}', + }, + }, + result: 'http://localhost/?={{Name}}/{{ID}}', + }, + { + href: 'http://localhost/?={{}}/{{}}', + vars: { + Name: 'name', + ID: 'id', + }, + result: 'http://localhost/?={{}}/{{}}', + }, + { + href: 'http://localhost/?={{Service_Name}}/{{Meta-Key}}', + vars: { + Service_Name: 'name', + ['Meta-Key']: 'id', + }, + result: 'http://localhost/?=name/id', + }, + { + href: 'http://localhost/?={{Service_Name}}/{{Meta-Key}}', + vars: { + WrongPropertyName: 'name', + ['Meta-Key']: 'id', + }, + result: 'http://localhost/?=/id', + }, + { + href: 'http://localhost/?={{.Name}}', + vars: { + ['.Name']: 'name', + }, + result: 'http://localhost/?=', + }, + { + href: 'http://localhost/?={{.}}', + vars: { + ['.']: 'name', + }, + result: 'http://localhost/?=', + }, + { + href: 'http://localhost/?={{deep..Name}}', + vars: { + deep: { + Name: 'Name', + ID: 'ID', + }, + }, + result: 'http://localhost/?=', + }, + ].forEach(item => { + this.set('item', item); + this.render(hbs` + {{#templated-anchor href=item.href vars=item.vars}} + Dashboard link + {{/templated-anchor}} + `); + assert.equal( + this.$() + .find('a') + .attr('href'), + item.result + ); + }); +}); diff --git a/ui-v2/tests/unit/routes/dc/services/show-test.js b/ui-v2/tests/unit/routes/dc/services/show-test.js index 773bf7326dfc..349c9cace225 100644 --- a/ui-v2/tests/unit/routes/dc/services/show-test.js +++ b/ui-v2/tests/unit/routes/dc/services/show-test.js @@ -2,7 +2,7 @@ import { moduleFor, test } from 'ember-qunit'; moduleFor('route:dc/services/show', 'Unit | Route | dc/services/show', { // Specify the other units that are required for this test. - needs: ['service:repository/service'], + needs: ['service:repository/service', 'service:settings'], }); test('it exists', function(assert) {