ui: New Empty States (#7940)

* ui: CSS and component changes to the <EmptyState /> component

* ui: Reset the auth-form component back to its initial state

Moving forwards we are going to have the auth-form on the page all the
time, even when logged in (for relogging in purposes). This means the
auth-form will not always be removed from the DOM when you log in.

This sets the form back to its idle state before calling onsubmit

* ui: Make a public api for modal-dialog with a single close method

* ui : Move cache reset somewhere that makes more sense, + single refresh

1. Centralize cache resetting elsewhere, for now the store makes most
sense, although I would prefer the Repository class, so using the store
is temporary
2. We only need to refresh on login once, unless we have a differing
nspace

* ui: Ensure visibilitychange events are cleaned up

* ui: Only cache DataSource data if we have any, + only clear the cache

* ui: Add the modal login dialog to both unauth and auth views

This means we can 'relogin' when already logged in

* ui: Add new empty states

* ui: CSS Tweaks

* Remove marketing grays
pull/8013/head
John Cowen 2020-05-27 11:23:21 +01:00 committed by John Cowen
parent c4b2fcbd38
commit 94dd1849b4
23 changed files with 298 additions and 95 deletions

View File

@ -93,7 +93,7 @@
@nspace={{or value.Namespace nspace}}
@type={{if value.Name 'oidc' 'secret'}}
@value={{if value.Name value.Name value}}
@onchange={{action onsubmit}}
@onchange={{queue (action dispatch "RESET") (action onsubmit)}}
@onerror={{queue (action (mut error) value="error.errors.firstObject") (action dispatch "ERROR")}}
/>
</State>

View File

@ -8,11 +8,24 @@
{{yield}}
{{/yield-slot}}
</header>
<p>
{{#yield-slot name="body"}}
{{#yield-slot name="body"}}
<div>
{{yield}}
{{/yield-slot}}
</p>
{{#if (and (env 'CONSUL_ACLS_ENABLED') allowLogin)}}
<label for="login-toggle">
<DataSource
@src="settings://consul:token"
@onchange={{action (mut token) value="data"}}
/>
{{#if token.AccessorID}}
Log in with a different token
{{else}}
Log in
{{/if}}
</label>
{{/if}}
</div>
{{/yield-slot}}
{{#yield-slot name="actions"}}
<ul>
{{yield}}

View File

@ -127,14 +127,15 @@
<AuthDialog
@dc={{dc.Name}}
@nspace={{nspace.Name}}
@onchange={{action onchange}} as |authDialog components|
@onchange={{action "reauthorize"}} as |authDialog components|
>
{{#let components.AuthForm components.AuthProfile as |AuthForm AuthProfile|}}
<BlockSlot @name="unauthorized">
<label tabindex="0" for="login-toggle" onkeypress={{action 'keypressClick'}}>
<span>Log in</span>
</label>
<ModalDialog @name="login-toggle" @onclose={{action 'close'}} @onopen={{action 'open'}}>
<ModalDialog @name="login-toggle" @onclose={{action 'close'}} @onopen={{action 'open'}} as |api|>
<Ref @target={{this}} @name="modal" @value={{api}} />
<BlockSlot @name="header">
<h2>Log in to Consul</h2>
</BlockSlot>
@ -151,6 +152,22 @@
</ModalDialog>
</BlockSlot>
<BlockSlot @name="authorized">
<ModalDialog @name="login-toggle" @onclose={{action 'close'}} @onopen={{action 'open'}} as |api|>
<Ref @target={{this}} @name="modal" @value={{api}} />
<BlockSlot @name="header">
<h2>Log in with a different token</h2>
</BlockSlot>
<BlockSlot @name="body">
<AuthForm as |api|>
<Ref @target={{this}} @name="authForm" @value={{api}} />
</AuthForm>
</BlockSlot>
<BlockSlot @name="actions" as |close|>
<button type="button" onclick={{action close}}>
Continue without logging in
</button>
</BlockSlot>
</ModalDialog>
<PopoverMenu @position="right">
<BlockSlot @name="trigger">
Logout

View File

@ -28,6 +28,10 @@ export default Component.extend({
close: function() {
this.authForm.reset();
},
reauthorize: function(e) {
this.modal.close();
this.onchange(e);
},
change: function(e) {
const win = this.dom.viewport();
const $root = this.dom.root();

View File

@ -6,13 +6,25 @@
<div>
<header>
<label for="modal_close">Close</label>
<YieldSlot @name="header">{{yield}}</YieldSlot>
<YieldSlot @name="header">
{{yield (hash
close=(action "close")
)}}
</YieldSlot>
</header>
<div>
<YieldSlot @name="body">{{yield}}</YieldSlot>
<YieldSlot @name="body">
{{yield (hash
close=(action "close")
)}}
</YieldSlot>
</div>
<footer>
<YieldSlot @name="actions" @params={{block-params (action "close")}}>{{yield}}</YieldSlot>
<YieldSlot @name="actions" @params={{block-params (action "close")}}>
{{yield (hash
close=(action "close")
)}}
</YieldSlot>
</footer>
</div>
</div>

View File

@ -6,9 +6,6 @@ import transitionable from 'consul-ui/utils/routing/transitionable';
export default Controller.extend({
router: service('router'),
http: service('repository/type/event-source'),
dataSource: service('data-source/service'),
client: service('client/http'),
store: service('store'),
feedback: service('feedback'),
actions: {
@ -23,12 +20,11 @@ export default Controller.extend({
// used for the feedback service.
this.feedback.execute(
() => {
// TODO: Centralize this elsewhere
this.client.abort();
this.http.resetCache();
this.dataSource.resetCache();
this.store.init();
// TODO: Currently we clear cache from the ember-data store
// ideally this would be a static method of the abstract Repository class
// once we move to proper classes for services take another look at this.
this.store.clear();
//
const params = {};
if (e.data) {
const token = e.data;
@ -42,22 +38,24 @@ export default Controller.extend({
}
}
}
const container = getOwner(this);
const routeName = this.router.currentRoute.name;
const route = getOwner(this).lookup(`route:${routeName}`);
const router = this.router;
const route = container.lookup(`route:${routeName}`);
// Refresh the application route
return getOwner(this)
return container
.lookup('route:application')
.refresh()
.promise.then(() => {
// We use transitionable here as refresh doesn't work if you are on an error page
// which is highly likely to happen here (403s)
if (routeName !== router.currentRouteName || typeof params.nspace !== 'undefined') {
.promise.then(res => {
// Use transitionable if we need to change a section of the URL
if (
routeName !== this.router.currentRouteName ||
typeof params.nspace !== 'undefined'
) {
return route.transitionTo(
...transitionable(router.currentRoute, params, getOwner(this))
...transitionable(this.router.currentRoute, params, container)
);
} else {
return route.refresh();
return res;
}
});
},

View File

@ -69,19 +69,27 @@ export default Service.extend({
settings: service('settings'),
init: function() {
this._super(...arguments);
this._listeners = this.dom.listeners();
const maxConnections = env('CONSUL_HTTP_MAX_CONNECTIONS');
set(this, 'connections', getObjectPool(dispose, maxConnections));
if (typeof maxConnections !== 'undefined') {
set(this, 'maxConnections', maxConnections);
const doc = this.dom.document();
// when the user hides the tab, abort all connections
doc.addEventListener('visibilitychange', e => {
if (e.target.hidden) {
this.connections.purge();
}
this._listeners.add(this.dom.document(), {
visibilitychange: e => {
if (e.target.hidden) {
this.connections.purge();
}
},
});
}
},
willDestroy: function() {
this._listeners.remove();
this.connections.purge();
set(this, 'connections', undefined);
this._super(...arguments);
},
url: function() {
return url(...arguments);
},
@ -235,14 +243,18 @@ export default Service.extend({
this.connections.purge();
},
whenAvailable: function(e) {
const doc = this.dom.document();
// if we are using a connection limited protocol and the user has hidden the tab (hidden browser/tab switch)
// any aborted errors should restart
const doc = this.dom.document();
if (typeof this.maxConnections !== 'undefined' && doc.hidden) {
return new Promise(function(resolve) {
doc.addEventListener('visibilitychange', function listen(event) {
doc.removeEventListener('visibilitychange', listen);
resolve(e);
return new Promise(resolve => {
const remove = this._listeners.add(doc, {
visibilitychange: function(event) {
remove();
// we resolve with the event that comes from
// whenAvailable not visibilitychange
resolve(e);
},
});
});
}

View File

@ -18,22 +18,17 @@ export default Service.extend({
init: function() {
this._super(...arguments);
if (cache === null) {
this.resetCache();
}
this._listeners = this.dom.listeners();
},
resetCache: function() {
Object.entries(sources || {}).forEach(function([key, item]) {
item.close();
});
cache = new Map();
sources = new Map();
usage = new MultiMap(Set);
this._listeners = this.dom.listeners();
},
resetCache: function() {
cache = new Map();
},
willDestroy: function() {
this._listeners.remove();
Object.entries(sources || {}).forEach(function([key, item]) {
sources.forEach(function(item) {
item.close();
});
cache = null;
@ -61,10 +56,15 @@ export default Service.extend({
close: e => {
const source = e.target;
source.removeEventListener('close', close);
cache.set(uri, {
currentEvent: source.getCurrentEvent(),
cursor: source.configuration.cursor,
});
const event = source.getCurrentEvent();
const cursor = source.configuration.cursor;
// only cache data if we have any
if (typeof event !== 'undefined' && typeof cursor !== 'undefined') {
cache.set(uri, {
currentEvent: source.getCurrentEvent(),
cursor: source.configuration.cursor,
});
}
// the data is cached delete the EventSource
sources.delete(uri);
},

View File

@ -1,6 +1,21 @@
import Store from 'ember-data/store';
import { inject as service } from '@ember/service';
export default Store.extend({
// TODO: This should eventually go on a static method
// of the abstract Repository class
http: service('repository/type/event-source'),
dataSource: service('data-source/service'),
client: service('client/http'),
clear: function() {
// Aborting the client will close all open http type sources
this.client.abort();
// once they are closed clear their caches
this.http.resetCache();
this.dataSource.resetCache();
this.init();
},
//
// TODO: These only exist for ACLs, should probably make sure they fail
// nicely if you aren't on ACLs for good DX
// cloning immediately refreshes the view

View File

@ -58,16 +58,7 @@ $cyan-600: #009fd9;
$cyan-700: #0077a3;
$cyan-800: #005574;
$cyan-900: #003346;
$gray-1: #191a1c;
$gray-2: #323538;
$gray-3: #4c4f54;
$gray-4: #656a70;
$gray-5: #7f858d;
$gray-6: #9a9ea5;
$gray-7: #b4b8bc;
$gray-8: #d0d2d5;
$gray-9: #ebecee;
$gray-10: #f3f4f6;
$gray-010: #fbfbfc;
$gray-050: #f7f8fa;
$gray-100: #ebeef2;
$gray-200: #dce0e6;

View File

@ -16,3 +16,6 @@
%empty-state > ul > li {
@extend %with-popover-menu;
}
%empty-state label {
@extend %primary-button;
}

View File

@ -1,15 +1,28 @@
%empty-state,
%empty-state > div {
display: flex;
flex-direction: column;
}
%empty-state-header {
padding: 0;
margin: 0;
}
%empty-state {
width: 320px;
margin-top: 0 !important;
padding-bottom: 2.8em;
}
%empty-state > * {
width: 370px;
margin: 0 auto;
}
%empty-state label {
margin: 0 auto !important;
}
%empty-state-header {
margin-bottom: -3px;
}
%empty-state header {
margin-top: 1.8em;
margin-bottom: 0.5em;
}
%empty-state > ul {

View File

@ -1,5 +1,6 @@
%empty-state {
color: $gray-500;
background-color: $gray-010;
}
%empty-state > ul {
border-color: $gray-300;
@ -34,12 +35,16 @@
%empty-state[class*='status-5'] header::before {
@extend %with-alert-circle-outline-mask;
}
%empty-state .docs-link > *::before {
@extend %with-docs-mask, %as-pseudo;
%empty-state li[class*='-link'] > *::after {
@extend %as-pseudo;
margin-left: 5px;
}
%empty-state .back-link > *::before {
@extend %with-chevron-left-mask, %as-pseudo;
%empty-state .docs-link > *::after {
@extend %with-docs-mask;
}
%empty-state .learn-link > *::before {
@extend %with-learn-mask, %as-pseudo;
%empty-state .back-link > *::after {
@extend %with-chevron-left-mask;
}
%empty-state .learn-link > *::after {
@extend %with-learn-mask;
}

View File

@ -1,4 +1,4 @@
<EmptyState class="status-403">
<EmptyState class="status-403" @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>You are not authorized</h2>
</BlockSlot>

View File

@ -4,7 +4,7 @@
</BlockSlot>
<BlockSlot @name="body">
<p>
ACLs are not enabled. We strongly encourage the use of ACLs in production environments for the best security practices.
ACLs are not enabled in this Consul cluster. We strongly encourage the use of ACLs in production environments for the best security practices.
</p>
</BlockSlot>
<BlockSlot @name="actions">

View File

@ -128,9 +128,24 @@
</TabularCollection>
</BlockSlot>
<BlockSlot @name="empty">
<p>
There are no ACLs.
</p>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>No ACLs</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
There don't seem to be any ACLs yet, or you may not have access to view ACLs yet.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}" rel="noopener noreferrer" target="_blank">Read the documentation</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/" rel="noopener noreferrer" target="_blank">Follow the guide</a>
</li>
</BlockSlot>
</EmptyState>
</BlockSlot>
</ChangeableSet>
</BlockSlot>

View File

@ -101,9 +101,24 @@
</TabularCollection>
</BlockSlot>
<BlockSlot @name="empty">
<p>
There are no Policies.
</p>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>Welcome to Policies</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
There don't seem to be any policies, or you may not have access to view policies yet.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/commands/acl/policy" rel="noopener noreferrer" target="_blank">Documentation on policies</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_LEARN_URL'}}/consul/security-networking/managing-acl-policies" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
</BlockSlot>
</ChangeableSet>
</BlockSlot>

View File

@ -96,9 +96,24 @@
</TabularCollection>
</BlockSlot>
<BlockSlot @name="empty">
<p>
There are no Roles.
</p>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>Welcome to Roles</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
There don't seem to be any roles, or you may not have access to view roles yet.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/commands/acl/role" rel="noopener noreferrer" target="_blank">Documentation on roles</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_API_URL'}}/acl/roles.html" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
</BlockSlot>
</ChangeableSet>
</BlockSlot>

View File

@ -26,9 +26,24 @@
/>
</BlockSlot>
<BlockSlot @name="empty">
<p>
There are no intentions.
</p>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>Welcome to Intentions</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
There don't seem to be any intentions, or you may not have access to view intentions yet.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/commands/intention" rel="noopener noreferrer" target="_blank">Documentation on intentions</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/getting-started/connect" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
</BlockSlot>
</ChangeableSet>
</BlockSlot>

View File

@ -87,9 +87,24 @@
</TabularCollection>
</BlockSlot>
<BlockSlot @name="empty">
<p>
There are no Key / Value pairs.
</p>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>Welcome to Key/Value</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
You don't have any K/V pairs, or you may not have access to view K/V pairs yet.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/agent/kv" rel="noopener noreferrer" target="_blank">Documentation on K/V</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/getting-started/kv" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
</BlockSlot>
</ChangeableSet>
</BlockSlot>

View File

@ -64,9 +64,24 @@
</div>
{{/if}}
{{#if (and (eq healthy.length 0) (eq unhealthy.length 0)) }}
<p>
There are no nodes.
</p>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>Welcome to Nodes</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
There don't seem to be any nodes, or you may not have access to view nodes yet.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}" rel="noopener noreferrer" target="_blank">Documentation on nodes</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
{{/if}}
</BlockSlot>
</AppView>

View File

@ -90,9 +90,24 @@
</TabularCollection>
</BlockSlot>
<BlockSlot @name="empty">
<p>
There are no Namespaces.
</p>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>Welcome to Namespaces</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
There don't seem to be any namespaces, or you may not have access to view namespaces yet.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/commands/namespace" rel="noopener noreferrer" target="_blank">Documentation on namespaces</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/namespaces/secure-namespaces" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
</BlockSlot>
</ChangeableSet>
</BlockSlot>

View File

@ -33,9 +33,24 @@
<ConsulServiceList @routeName="dc.services.show" @items={{sort-by sort.selected.key filtered}} @proxies={{proxies}}/>
</BlockSlot>
<BlockSlot @name="empty">
<p>
There are no services.
</p>
<EmptyState @allowLogin={{true}}>
<BlockSlot @name="header">
<h2>Welcome to Services</h2>
</BlockSlot>
<BlockSlot @name="body">
<p>
There don't seem to be any registered services, or you may not have access to view services yet.
</p>
</BlockSlot>
<BlockSlot @name="actions">
<li class="docs-link">
<a href="{{env 'CONSUL_DOCS_URL'}}/commands/services" rel="noopener noreferrer" target="_blank">Documentation on services</a>
</li>
<li class="learn-link">
<a href="{{env 'CONSUL_DOCS_LEARN_URL'}}/consul/getting-started/services" rel="noopener noreferrer" target="_blank">Read the guide</a>
</li>
</BlockSlot>
</EmptyState>
</BlockSlot>
</ChangeableSet>
</BlockSlot>