Merge pull request #7857 from hashicorp/ui-staging-1-8

UI Release Merge (1.8: ui-staging merge)
pull/7865/head
John Cowen 2020-05-12 19:24:11 +01:00 committed by GitHub
commit 6f9e511d99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
662 changed files with 13897 additions and 6225 deletions

View File

@ -5,13 +5,13 @@ references:
images:
go: &GOLANG_IMAGE circleci/golang:1.14.1
middleman: &MIDDLEMAN_IMAGE hashicorp/middleman-hashicorp:0.3.40
ember: &EMBER_IMAGE circleci/node:8-browsers
ember: &EMBER_IMAGE circleci/node:12-browsers
paths:
test-results: &TEST_RESULTS_DIR /tmp/test-results
cache:
yarn: &YARN_CACHE_KEY consul-ui-v1-{{ checksum "ui-v2/yarn.lock" }}
yarn: &YARN_CACHE_KEY consul-ui-v2-{{ checksum "ui-v2/yarn.lock" }}
rubygem: &RUBYGEM_CACHE_KEY static-site-gems-v1-{{ checksum "Gemfile.lock" }}
environment: &ENVIRONMENT
@ -461,6 +461,9 @@ jobs:
- image: *EMBER_IMAGE
environment:
EMBER_TEST_REPORT: test-results/report-oss.xml #outputs test report for CircleCI test summary
EMBER_TEST_PARALLEL: true #enables test parallelization with ember-exam
CONSUL_NSPACES_ENABLED: 0
parallelism: 2
steps:
- checkout
- restore_cache:
@ -469,7 +472,7 @@ jobs:
at: ui-v2
- run:
working_directory: ui-v2
command: make test-oss-ci
command: node_modules/.bin/ember exam --split=$CIRCLE_NODE_TOTAL --partition=`expr $CIRCLE_NODE_INDEX + 1` --path dist --silent -r xunit
- store_test_results:
path: ui-v2/test-results
# run ember frontend tests
@ -478,6 +481,8 @@ jobs:
- image: *EMBER_IMAGE
environment:
EMBER_TEST_REPORT: test-results/report-ent.xml #outputs test report for CircleCI test summary
EMBER_TEST_PARALLEL: true #enables test parallelization with ember-exam
parallelism: 2
steps:
- checkout
- restore_cache:
@ -486,9 +491,26 @@ jobs:
at: ui-v2
- run:
working_directory: ui-v2
command: make test-ci
command: node_modules/.bin/ember exam --split=$CIRCLE_NODE_TOTAL --partition=`expr $CIRCLE_NODE_INDEX + 1` --path dist --silent -r xunit
- store_test_results:
path: ui-v2/test-results
# run ember frontend unit tests to produce coverage report
ember-coverage:
docker:
- image: *EMBER_IMAGE
steps:
- checkout
- restore_cache:
key: *YARN_CACHE_KEY
- attach_workspace:
at: ui-v2
- run:
working_directory: ui-v2
command: make test-coverage-ci
- run:
name: codecov ui upload
working_directory: ui-v2
command: bash <(curl -s https://codecov.io/bash) -v -c -C $CIRCLE_SHA1 -F ui
envoy-integration-test-1.11.2:
docker:
@ -682,6 +704,9 @@ workflows:
- ember-test-ent:
requires:
- ember-build
- ember-coverage:
requires:
- ember-build
cherry-pick:
jobs:
- cherry-picker:

View File

@ -1,7 +1,7 @@
ARG ALPINE_VERSION=3.9
ARG ALPINE_VERSION=3.11
FROM alpine:${ALPINE_VERSION}
ARG NODEJS_VERSION=10.14.2-r0
ARG NODEJS_VERSION=12.15.0-r1
ARG MAKE_VERSION=4.2.1-r2
ARG YARN_VERSION=1.19.1

View File

@ -10,6 +10,8 @@ coverage:
# https://docs.codecov.io/docs/commit-status#section-excluding-tests-example-
# TODO: should any paths be excluded from coverage metrics?
# paths:
ui:
informational: true
# https://docs.codecov.io/docs/commit-status#section-changes-status
# TODO: enable after eliminating current unexpected coverage changes?
changes: off
@ -29,7 +31,9 @@ comment: false
# https://docs.codecov.io/docs/flags
# TODO: split out test coverage for API, SDK, UI, website?
# flags:
flags:
ui:
paths: /ui-v2/
ignore:
- "agent/bindata_assetfs.go"

View File

@ -4,7 +4,6 @@
root = true
[*]
end_of_line = lf
charset = utf-8

View File

@ -5,5 +5,14 @@
Setting `disableAnalytics` to true will prevent any data from being sent.
*/
"disableAnalytics": false
"disableAnalytics": false,
/**
We use a nested in /components folder structure:
/components/component-name/index.{hbs,js}
*/
"componentStructure": "nested",
/**
We currently use classic components
*/
"componentClass": "@ember/component"
}

View File

@ -1,8 +1,12 @@
module.exports = {
root: true,
parser: 'babel-eslint',
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module'
sourceType: 'module',
ecmaFeatures: {
legacyDecorators: true
}
},
plugins: ['ember'],
extends: ['eslint:recommended', 'plugin:ember/recommended'],
@ -11,7 +15,9 @@ module.exports = {
},
rules: {
'no-unused-vars': ['error', { args: 'none' }],
'ember/no-new-mixins': ['warn']
'ember/no-new-mixins': ['warn'],
'ember/no-jquery': 'warn',
'ember/no-global-jquery': 'warn'
},
overrides: [
// node files

4
ui-v2/.istanbul.yml Normal file
View File

@ -0,0 +1,4 @@
instrumentation:
excludes: [
"!app/+(utils|search)/**/*"
]

View File

@ -1 +1 @@
10
12

View File

@ -1,7 +1,7 @@
'use strict';
module.exports = {
extends: 'recommended',
extends: 'octane',
rules: {
'no-partial': false,
@ -10,6 +10,7 @@ module.exports = {
'self-closing-void-elements': false,
'no-unnecessary-concat': false,
'no-quoteless-attributes': false,
'no-nested-interactive': false,
'block-indentation': false,
@ -19,6 +20,15 @@ module.exports = {
'no-triple-curlies': false,
'no-unused-block-params': false,
'style-concatenation': false,
'link-rel-noopener': false
'link-rel-noopener': false,
'no-implicit-this': false,
'no-curly-component-invocation': false,
'no-action': false,
'no-negated-condition': false,
'no-invalid-role': false,
'no-unnecessary-component-helper': false,
'link-href-attributes': false
},
};

27
ui-v2/.travis.yml Normal file
View File

@ -0,0 +1,27 @@
---
language: node_js
node_js:
- "10"
dist: trusty
addons:
chrome: stable
cache:
directories:
- $HOME/.npm
env:
global:
# See https://git.io/vdao3 for details.
- JOBS=1
branches:
only:
- master
script:
- npm run lint:hbs
- npm run lint:js
- npm test

View File

@ -65,6 +65,15 @@ test-oss-ci: deps test-node
test-node:
yarn run test:node
test-coverage: deps
yarn run test:coverage
test-coverage-view: deps
yarn run test:coverage:view
test-coverage-ci: deps
yarn run test:coverage:ci
test-parallel: deps
yarn run test:parallel

View File

@ -1,14 +1,13 @@
import Adapter from './http';
import { inject as service } from '@ember/service';
import { env } from 'consul-ui/env';
export const DATACENTER_QUERY_PARAM = 'dc';
export const NSPACE_QUERY_PARAM = 'ns';
export default Adapter.extend({
repo: service('settings'),
client: service('client/http'),
env: service('env'),
formatNspace: function(nspace) {
if (env('CONSUL_NSPACES_ENABLED')) {
if (this.env.env('CONSUL_NSPACES_ENABLED')) {
return nspace !== '' ? { [NSPACE_QUERY_PARAM]: nspace } : undefined;
}
},
@ -17,45 +16,9 @@ export default Adapter.extend({
[DATACENTER_QUERY_PARAM]: dc,
};
},
// TODO: kinda protected for the moment
// decide where this should go either read/write from http
// should somehow use this or vice versa
// TODO: Deprecated, remove `request` usage from everywhere and replace with
// `HTTPAdapter.rpc`
request: function(req, resp, obj, modelName) {
const client = this.client;
const store = this.store;
const adapter = this;
let unserialized, serialized;
const serializer = store.serializerFor(modelName);
// workable way to decide whether this is a snapshot
// essentially 'is attributable'.
// Snapshot is private so we can't do instanceof here
// and using obj.constructor.name gets changed/minified
// during compilation so you can't rely on it
// checking for `attributes` being a function is more
// reliable as that is the thing we need to call
if (typeof obj.attributes === 'function') {
unserialized = obj.attributes();
serialized = serializer.serialize(obj, {});
} else {
unserialized = obj;
serialized = unserialized;
}
return client
.request(function(request) {
return req(adapter, request, serialized, unserialized);
})
.catch(function(e) {
return adapter.error(e);
})
.then(function(respond) {
// TODO: When HTTPAdapter:responder changes, this will also need to change
return resp(serializer, respond, serialized, unserialized);
});
// TODO: Potentially add specific serializer errors here
// .catch(function(e) {
// return Promise.reject(e);
// });
return this.rpc(...arguments);
},
});

View File

@ -1,7 +1,7 @@
import Adapter from './application';
export default Adapter.extend({
requestForFindAll: function(request) {
requestForQuery: function(request) {
return request`
GET /v1/catalog/datacenters
`;

View File

@ -1,3 +1,4 @@
import { inject as service } from '@ember/service';
import Adapter from 'ember-data/adapter';
import AdapterError from '@ember-data/adapter/error';
import {
@ -10,46 +11,75 @@ import {
ConflictError,
InvalidError,
} from 'ember-data/adapters/errors';
// TODO: This is a little skeleton cb function
// is to be replaced soon with something slightly more involved
const responder = function(response) {
return response;
};
const read = function(adapter, serializer, client, type, query) {
return client
.request(function(request) {
// TODO These are now exactly the same, apart from the fact that one uses
// `serialized, unserialized` and the other just `query`
// they could actually be one function now, but would be nice to think about
// the naming of things (serialized vs query etc)
const read = function(adapter, modelName, type, query = {}) {
return adapter.rpc(
function(adapter, request, query) {
return adapter[`requestFor${type}`](request, query);
})
.catch(function(e) {
return adapter.error(e);
})
.then(function(response) {
return serializer[`respondFor${type}`](responder(response), query);
});
// TODO: Potentially add specific serializer errors here
// .catch(function(e) {
// return Promise.reject(e);
// });
},
function(serializer, respond, query) {
return serializer[`respondFor${type}`](respond, query);
},
query,
modelName
);
};
const write = function(adapter, serializer, client, type, snapshot) {
const unserialized = snapshot.attributes();
const serialized = serializer.serialize(snapshot, {});
return client
.request(function(request) {
const write = function(adapter, modelName, type, snapshot) {
return adapter.rpc(
function(adapter, request, serialized, unserialized) {
return adapter[`requestFor${type}`](request, serialized, unserialized);
})
.catch(function(e) {
return adapter.error(e);
})
.then(function(response) {
return serializer[`respondFor${type}`](responder(response), serialized, unserialized);
});
// TODO: Potentially add specific serializer errors here
// .catch(function(e) {
// return Promise.reject(e);
// });
},
function(serializer, respond, serialized, unserialized) {
return serializer[`respondFor${type}`](respond, serialized, unserialized);
},
snapshot,
modelName
);
};
export default Adapter.extend({
client: service('client/http'),
rpc: function(req, resp, obj, modelName) {
const client = this.client;
const store = this.store;
const adapter = this;
let unserialized, serialized;
const serializer = store.serializerFor(modelName);
// workable way to decide whether this is a snapshot
// essentially 'is attributable'.
// Snapshot is private so we can't do instanceof here
// and using obj.constructor.name gets changed/minified
// during compilation so you can't rely on it
// checking for `attributes` being a function is more
// reliable as that is the thing we need to call
if (typeof obj.attributes === 'function') {
unserialized = obj.attributes();
serialized = serializer.serialize(obj, {});
} else {
unserialized = obj;
serialized = unserialized;
}
return client
.request(function(request) {
return req(adapter, request, serialized, unserialized);
})
.catch(function(e) {
return adapter.error(e);
})
.then(function(respond) {
// TODO: When HTTPAdapter:responder changes, this will also need to change
return resp(serializer, respond, serialized, unserialized);
});
// TODO: Potentially add specific serializer errors here
// .catch(function(e) {
// return Promise.reject(e);
// });
},
error: function(err) {
const errors = [
{
@ -94,24 +124,28 @@ export default Adapter.extend({
} catch (e) {
error = e;
}
// TODO: This comes originates from ember-data
// This can be confusing if you need to use this with Promise.reject
// Consider changing this to return the error and then
// throw from the call site instead
throw error;
},
query: function(store, type, query) {
return read(this, store.serializerFor(type.modelName), this.client, 'Query', query);
return read(this, type.modelName, 'Query', query);
},
queryRecord: function(store, type, query) {
return read(this, store.serializerFor(type.modelName), this.client, 'QueryRecord', query);
return read(this, type.modelName, 'QueryRecord', query);
},
findAll: function(store, type) {
return read(this, store.serializerFor(type.modelName), this.client, 'FindAll');
return read(this, type.modelName, 'FindAll');
},
createRecord: function(store, type, snapshot) {
return write(this, store.serializerFor(type.modelName), this.client, 'CreateRecord', snapshot);
return write(this, type.modelName, 'CreateRecord', snapshot);
},
updateRecord: function(store, type, snapshot) {
return write(this, store.serializerFor(type.modelName), this.client, 'UpdateRecord', snapshot);
return write(this, type.modelName, 'UpdateRecord', snapshot);
},
deleteRecord: function(store, type, snapshot) {
return write(this, store.serializerFor(type.modelName), this.client, 'DeleteRecord', snapshot);
return write(this, type.modelName, 'DeleteRecord', snapshot);
},
});

View File

@ -6,11 +6,14 @@ import { SLUG_KEY } from 'consul-ui/models/intention';
// TODO: Update to use this.formatDatacenter()
export default Adapter.extend({
requestForQuery: function(request, { dc, index, id }) {
requestForQuery: function(request, { dc, filter, index }) {
return request`
GET /v1/connect/intentions?${{ dc }}
${{ index }}
${{
index,
filter,
}}
`;
},
requestForQueryRecord: function(request, { dc, index, id }) {

View File

@ -0,0 +1,97 @@
import Adapter from './application';
import { inject as service } from '@ember/service';
import { env } from 'consul-ui/env';
import nonEmptySet from 'consul-ui/utils/non-empty-set';
let Namespace;
if (env('CONSUL_NSPACES_ENABLED')) {
Namespace = nonEmptySet('Namespace');
} else {
Namespace = () => ({});
}
export default Adapter.extend({
env: service('env'),
requestForQuery: function(request, { dc, ns, index }) {
return request`
GET /v1/internal/ui/oidc-auth-methods?${{ dc }}
${{
index,
...this.formatNspace(ns),
}}
`;
},
requestForQueryRecord: function(request, { dc, ns, id }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
return request`
POST /v1/acl/oidc/auth-url?${{ dc }}
Cache-Control: no-store
${{
...Namespace(ns),
AuthMethod: id,
RedirectURI: `${this.env.var('CONSUL_BASE_UI_URL')}/oidc/callback`,
}}
`;
},
requestForAuthorize: function(request, { dc, ns, id, code, state }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
if (typeof code === 'undefined') {
throw new Error('You must specify an code');
}
if (typeof state === 'undefined') {
throw new Error('You must specify an state');
}
return request`
POST /v1/acl/oidc/callback?${{ dc }}
Cache-Control: no-store
${{
...Namespace(ns),
AuthMethod: id,
Code: code,
State: state,
}}
`;
},
requestForLogout: function(request, { id }) {
if (typeof id === 'undefined') {
throw new Error('You must specify an id');
}
return request`
POST /v1/acl/logout
Cache-Control: no-store
X-Consul-Token: ${id}
`;
},
authorize: function(store, type, id, snapshot) {
return this.request(
function(adapter, request, serialized, unserialized) {
return adapter.requestForAuthorize(request, serialized, unserialized);
},
function(serializer, respond, serialized, unserialized) {
return serializer.respondForAuthorize(respond, serialized, unserialized);
},
snapshot,
type.modelName
);
},
logout: function(store, type, id, snapshot) {
return this.request(
function(adapter, request, serialized, unserialized) {
return adapter.requestForLogout(request, serialized, unserialized);
},
function(serializer, respond, serialized, unserialized) {
// its ok to return nothing here for the moment at least
return {};
},
snapshot,
type.modelName
);
},
});

View File

@ -104,6 +104,7 @@ export default Adapter.extend({
return request`
GET /v1/acl/token/self?${{ dc }}
X-Consul-Token: ${secret}
Cache-Control: no-store
${{ index }}
`;
@ -132,7 +133,7 @@ export default Adapter.extend({
return adapter.requestForSelf(request, serialized, data);
},
function(serializer, respond, serialized, data) {
return serializer.respondForQueryRecord(respond, serialized, data);
return serializer.respondForSelf(respond, serialized, data);
},
unserialized,
type.modelName

View File

@ -1,14 +1,12 @@
import Application from '@ember/application';
import Resolver from './resolver';
import Resolver from 'ember-resolver';
import loadInitializers from 'ember-load-initializers';
import config from './config/environment';
const App = Application.extend({
modulePrefix: config.modulePrefix,
podModulePrefix: config.podModulePrefix,
Resolver,
});
export default class App extends Application {
modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix;
Resolver = Resolver;
}
loadInitializers(App, config.modulePrefix);
export default App;

View File

@ -0,0 +1,4 @@
{{!<form>}}
<FreetextFilter @searchable={{searchable}} @value={{search}} @placeholder="Search by name/token" />
<RadioGroup @keyboardAccess={{true}} @name="type" @value={{type}} @items={{filters}} @onchange={{action onchange}} />
{{!</form>}}

View File

@ -0,0 +1,89 @@
{{yield}}
{{#if (not loading)}}
<header>
{{#each flashMessages.queue as |flash|}}
<FlashMessage @flash={{flash}} as |component flash|>
{{#let (lowercase component.flashType) (lowercase flash.action) as |status type|}}
{{! flashes automatically ucfirst the type }}
<p data-notification class={{concat status ' notification-' type}}>
<strong>
{{capitalize status}}!
</strong>
{{#yield-slot name="notification" params=(block-params status type flash.item)}}
{{yield}}
{{#if (eq type 'logout')}}
{{#if (eq status 'success') }}
You are now logged out.
{{else}}
There was an error logging out.
{{/if}}
{{else if (eq type 'authorize')}}
{{#if (eq status 'success') }}
You are now logged in.
{{else}}
There was an error, please check your SecretID/Token
{{/if}}
{{/if}}
{{else}}
{{#if (eq type 'logout')}}
{{#if (eq status 'success') }}
You are now logged out.
{{else}}
There was an error logging out.
{{/if}}
{{else if (eq type 'authorize')}}
{{#if (eq status 'success') }}
You are now logged in.
{{else}}
There was an error, please check your SecretID/Token
{{/if}}
{{/if}}
{{/yield-slot}}
</p>
{{/let}}
</FlashMessage>
{{/each}}
<div>
<div>
{{#if authorized}}
<nav aria-label="Breadcrumb">
<YieldSlot @name="breadcrumbs">{{yield}}</YieldSlot>
</nav>
{{/if}}
<div class="title">
<YieldSlot @name="header">
{{yield}}
</YieldSlot>
<div class="actions">
{{#if authorized}}
<YieldSlot @name="actions">{{yield}}</YieldSlot>
{{/if}}
</div>
</div>
<YieldSlot @name="nav">
{{yield}}
</YieldSlot>
</div>
</div>
{{#if authorized}}
<YieldSlot @name="toolbar">
<input type="checkbox" id="toolbar-toggle" />
{{yield}}
</YieldSlot>
{{/if}}
</header>
{{/if}}
<div>
{{#if loading}}
<ConsulLoader />
{{else}}
{{#if (not enabled) }}
<YieldSlot @name="disabled">{{yield}}</YieldSlot>
{{else if (not authorized)}}
<YieldSlot @name="authorization">{{yield}}</YieldSlot>
{{else}}
<YieldSlot @name="content">{{yield}}</YieldSlot>
{{/if}}
{{/if}}
</div>

View File

@ -0,0 +1,56 @@
## AuthDialog
```handlebars
<AuthDialog @dc={{dc}} @nspace={{}} @onchange={{action 'change'}} as |api components|>
{{#let components.AuthForm components.AuthProfile as |AuthForm AuthProfile|}}
<BlockSlot @name="unauthorized">
Here's the login form:
<AuthForm />
</BlockSlot>
<BlockSlot @name="authorized">
Here's your profile:
<AuthProfile />
<button onclick={{action api.logout}} />
</BlockSlot>
{{/let}}
</AuthDialog>
```
### Arguments
A component to help orchestrate a login/logout flow.
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `dc` | `String` | | The name of the current datacenter |
| `nspace` | `String` | | The name of the current namespace |
| `onchange` | `Function` | | An action to fire when the users token has changed (logged in/logged out/token changed) |
### Methods/Actions/api
| Method/Action | Description |
| --- | --- |
| `login` | Login with a specified token |
| `logout` | Logout (delete token) |
| `token` | The current token itself (as a property not a method) |
### Components
| Name | Description |
| --- | --- |
| [`AuthForm`](../auth-form/README.mdx) | Renders an Authorization form |
| [`AuthProfile`](../auth-profile/README.mdx) | Renders a User Profile |
### Slots
| Name | Description |
| --- | --- |
| `unauthorized` | This slot is only rendered when the user doesn't have a token |
| `authorized` | This slot is only rendered whtn the user has a token.|
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,34 @@
export default {
id: 'auth-dialog',
initial: 'idle',
on: {
CHANGE: [
{
target: 'authorized',
cond: 'hasToken',
actions: ['login'],
},
{
target: 'unauthorized',
actions: ['logout'],
},
],
},
states: {
idle: {
on: {
CHANGE: [
{
target: 'authorized',
cond: 'hasToken',
},
{
target: 'unauthorized',
},
],
},
},
unauthorized: {},
authorized: {},
},
};

View File

@ -0,0 +1,40 @@
<StateChart @src={{chart}} as |State Guard Action dispatch state|>
<Guard @name="hasToken" @cond={{action 'hasToken'}} />
<Action @name="login" @exec={{action 'login'}} />
<Action @name="logout" @exec={{action 'logout'}} />
{{! This DataSource just permanently listens to any changes to the users }}
{{! token, whether thats a new token, a changed token or a deleted token }}
<DataSource
@src="settings://consul:token"
@onchange={{queue (action (mut token) value="data") (action dispatch "CHANGE") (action (mut previousToken) value="data")}}
/>
{{! This DataSink is just used for logging in from the form, }}
{{! or logging out via the exposed logout function }}
<DataSink
@sink="settings://consul:token"
as |sink|
>
{{yield}}
{{#let (hash
login=(action sink.open)
logout=(action sink.open null)
token=token
) (hash
AuthProfile=(component 'auth-profile' item=token)
AuthForm=(component 'auth-form' dc=dc nspace=nspace onsubmit=(action sink.open value="data"))
) as |api components|}}
<State @matches="authorized">
{{#yield-slot name="authorized"}}
{{yield api components}}
{{/yield-slot}}
</State>
<State @matches="unauthorized">
{{#yield-slot name="unauthorized"}}
{{yield api components}}
{{/yield-slot}}
</State>
{{/let}}
</DataSink>
</StateChart>

View File

@ -0,0 +1,42 @@
import Component from '@ember/component';
import Slotted from 'block-slots';
import { inject as service } from '@ember/service';
import { get } from '@ember/object';
import chart from './chart.xstate';
export default Component.extend(Slotted, {
tagName: '',
repo: service('repository/oidc-provider'),
init: function() {
this._super(...arguments);
this.chart = chart;
},
actions: {
hasToken: function() {
return typeof this.token.AccessorID !== 'undefined';
},
login: function() {
let prev = get(this, 'previousToken.AccessorID');
let current = get(this, 'token.AccessorID');
if (prev === null) {
prev = get(this, 'previousToken.SecretID');
}
if (current === null) {
current = get(this, 'token.SecretID');
}
let type = 'authorize';
if (typeof prev !== 'undefined' && prev !== current) {
type = 'use';
}
this.onchange({ data: get(this, 'token'), type: type });
},
logout: function() {
if (typeof get(this, 'previousToken.AuthMethod') !== 'undefined') {
// we are ok to fire and forget here
this.repo.logout(get(this, 'previousToken.SecretID'));
}
this.previousToken = null;
this.onchange({ data: null, type: 'logout' });
},
},
});

View File

@ -0,0 +1,18 @@
## AuthForm
```handlebars
<AuthForm as |api|></AuthForm>
```
### Methods/Actions/api
| Method/Action | Description |
| --- | --- |
| `reset` | Reset the form back to its original empty/non-error state |
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,55 @@
export default {
id: 'auth-form',
initial: 'idle',
on: {
RESET: [
{
target: 'idle',
},
],
},
states: {
idle: {
entry: ['clearError'],
on: {
SUBMIT: [
{
target: 'loading',
cond: 'hasValue',
},
{
target: 'error',
},
],
},
},
loading: {
on: {
ERROR: [
{
target: 'error',
},
],
},
},
error: {
exit: ['clearError'],
on: {
TYPING: [
{
target: 'idle',
},
],
SUBMIT: [
{
target: 'loading',
cond: 'hasValue',
},
{
target: 'error',
},
],
},
},
},
};

View File

@ -0,0 +1,100 @@
<StateChart @src={{chart}} as |State Guard Action dispatch state|>
{{yield (hash
reset=(action dispatch "RESET")
focus=(action 'focus')
)}}
<Guard @name="hasValue" @cond={{action 'hasValue'}} />
{{!FIXME: Call this reset or similar }}
<Action @name="clearError" @exec={{queue (action (mut error) undefined) (action (mut secret) undefined)}} />
<div class="auth-form" ...attributes>
<State @matches="error">
{{#if error.status}}
<p role="alert" class="notice error">
{{#if value.Name}}
{{#if (eq error.status '403')}}
<strong>Consul login failed</strong><br />
We received a token from your OIDC provider but could not log in to Consul with it.
{{else if (eq error.status '401')}}
<strong>Could not log in to provider</strong><br />
The OIDC provider has rejected this access token. Please have an administrator check your auth method configuration.
{{else if (eq error.status '499')}}
<strong>SSO log in window closed</strong><br />
The OIDC provider window was closed. Please try again.
{{else}}
<strong>Error</strong><br />
{{error.detail}}
{{/if}}
{{else}}
{{#if (eq error.status '403')}}
<strong>Invalid token</strong><br />
The token entered does not exist. Please enter a valid token to log in.
{{else}}
<strong>Error</strong><br />
{{error.detail}}
{{/if}}
{{/if}}
</p>
{{/if}}
</State>
<form onsubmit={{action dispatch "SUBMIT"}}>
<fieldset>
<label class={{concat "type-password" (if (and (state-matches state 'error') (not error.status)) ' has-error' '')}}>
<span>Log in with a token</span>
<input
{{ref this 'input'}}
disabled={{state-matches state "loading"}}
type="password"
name="auth[SecretID]"
placeholder="SecretID"
value={{secret}}
oninput={{queue
(action (mut secret) value="target.value")
(action (mut value) value="target.value")
(action dispatch "TYPING")
}}
/>
<State @matches="error">
{{#if (not error.status)}}
<strong role="alert">
Please enter your secret
</strong>
{{/if}}
</State>
</label>
</fieldset>
<button type="submit" disabled={{state-matches state "loading"}}>
Log in
</button>
<em>Contact your administrator for login credentials.</em>
</form>
{{#if (env 'CONSUL_SSO_ENABLED')}}
<DataSource
@src={{concat '/' (or nspace 'default') '/' dc '/oidc/providers'}}
@onchange={{queue (action (mut providers) value="data")}}
@onerror={{queue (action (mut error) value="error.errors.firstObject")}}
@loading="lazy"
/>
{{#if (gt providers.length 0)}}
<p>
<span>or</span>
</p>
{{/if}}
<OidcSelect
@items={{providers}}
@disabled={{state-matches state "loading"}}
@onchange={{queue (action (mut value)) (action dispatch "SUBMIT") }}
@onerror={{queue (action (mut error) value="error.errors.firstObject") (action dispatch "ERROR")}}
/>
{{/if}}
</div>
<State @matches="loading">
<TokenSource
@dc={{dc}}
@nspace={{or value.Namespace nspace}}
@type={{if value.Name 'oidc' 'secret'}}
@value={{if value.Name value.Name value}}
@onchange={{action onsubmit}}
@onerror={{queue (action (mut error) value="error.errors.firstObject") (action dispatch "ERROR")}}
/>
</State>
</StateChart>

View File

@ -0,0 +1,21 @@
import Component from '@ember/component';
import chart from './chart.xstate';
export default Component.extend({
tagName: '',
onsubmit: function(e) {},
onchange: function(e) {},
init: function() {
this._super(...arguments);
this.chart = chart;
},
actions: {
hasValue: function(context, event, meta) {
return this.value !== '' && typeof this.value !== 'undefined';
},
focus: function() {
this.input.focus();
},
},
});

View File

@ -0,0 +1,20 @@
## AuthProfile
```handlebars
<AuthProfile @item={{token}} />
```
A straightforward partial-like component for rendering a user profile.
### Arguments
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `item` | `Object` | | A Consul shaped token object (currently only requires an AccessorID property to be set |
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,9 @@
<dl>
<dt>
<span>My ACL Token</span><br />
AccessorID
</dt>
<dd>
{{substr item.AccessorID -8}}
</dd>
</dl>

View File

@ -0,0 +1,4 @@
{{!<form>}}
<FreetextFilter @searchable={{searchable}} @value={{search}} @placeholder="Search" />
<RadioGroup @keyboardAccess={{true}} @name="status" @value={{status}} @items={{array (hash label="All (Any Status)" value="") (hash label="Critical Checks" value="critical") (hash label="Warning Checks" value="warning") (hash label="Passing Checks" value="passing")}} @onchange={{action onchange}} />
{{!</form>}}

View File

@ -0,0 +1,10 @@
<form class="catalog-toolbar" data-test-catalog-toolbar>
<FreetextFilter @searchable={{searchable}} @value={{value}} @placeholder="Search" />
<PopoverSelect
data-popover-select
@selected={{selected}}
@options={{options}}
@onchange={{onchange}}
@title='Sort By'
/>
</form>

View File

@ -0,0 +1,6 @@
{{yield}}
{{#if (gt items.length 0)}}
<YieldSlot @name="set" @params={{block-params items}}>{{yield}}</YieldSlot>
{{else}}
<YieldSlot @name="empty">{{yield}}</YieldSlot>
{{/if}}

View File

@ -0,0 +1,29 @@
<div ...attributes>
{{yield}}
<YieldSlot @name="create">{{yield}}</YieldSlot>
<label class="type-text">
<span><YieldSlot @name="label">{{yield}}</YieldSlot></span>
{{#if isOpen}}
<DataSource
@src={{concat '/' (or nspace 'default') '/' dc '/' (pluralize type)}}
@onchange={{action (mut allOptions) value="data"}}
/>
{{/if}}
<PowerSelect
@search={{action "search"}}
@options={{options}}
@loadingMessage="Loading..."
@searchMessage="No possible options"
@searchPlaceholder={{placeholder}}
@onOpen={{action (mut isOpen) true}}
@onClose={{action (mut isOpen) false}}
@onChange={{action "change" "items[]" items}} as |item|>
<YieldSlot @name="option" @params={{block-params item}}>{{yield}}</YieldSlot>
</PowerSelect>
</label>
{{#if (gt items.length 0)}}
<YieldSlot @name="set">{{yield}}</YieldSlot>
{{else}}
{{/if}}
</div>

View File

@ -2,13 +2,13 @@ import Component from '@ember/component';
import { get, set, computed } from '@ember/object';
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import { Promise } from 'rsvp';
import SlotsMixin from 'block-slots';
import WithListeners from 'consul-ui/mixins/with-listeners';
export default Component.extend(SlotsMixin, WithListeners, {
onchange: function() {},
tagName: '',
error: function() {},
type: '',
@ -54,11 +54,6 @@ export default Component.extend(SlotsMixin, WithListeners, {
reset: function() {
this.form.clear({ Datacenter: this.dc, Namespace: this.nspace });
},
open: function() {
if (!get(this, 'allOptions.closed')) {
set(this, 'allOptions', this.repo.findAllByDatacenter(this.dc, this.nspace));
}
},
save: function(item, items, success = function() {}) {
// Specifically this saves an 'new' option/child
// and then adds it to the selectedOptions, not options
@ -69,20 +64,22 @@ export default Component.extend(SlotsMixin, WithListeners, {
// need to be sure that its saved before adding/closing the modal for now
// and we don't open the modal on prop change yet
item = repo.persist(item);
this.listen(item, 'message', e => {
this.actions.change.bind(this)(
{
target: {
name: 'items[]',
value: items,
this.listen(item, {
message: e => {
this.actions.change.apply(this, [
{
target: {
name: 'items[]',
value: items,
},
},
},
items,
e.data
);
success();
items,
e.data,
]);
success();
},
error: e => this.error(e),
});
this.listen(item, 'error', this.error.bind(this));
},
remove: function(item, items) {
const prop = this.repo.getSlugKey();

View File

@ -0,0 +1,11 @@
<IvyCodemirror @value={{value}} @name={{name}} @class={{class}} @options={{options}} @valueUpdated={{action onkeyup}} />
<pre><code>{{yield}}</code></pre>
{{#if (and (not readonly) (not syntax))}}
<PowerSelect
@onChange={{action "change"}}
@selected={{mode}}
@searchEnabled={{false}}
@options={{modes}} as |mode|>
{{mode.name}}
</PowerSelect>
{{/if}}

View File

@ -0,0 +1,11 @@
{{yield}}
<YieldSlot @name="action" @params={{block-params confirm cancel}}>
{{#if (or permanent (not confirming))}}
{{yield}}
{{/if}}
</YieldSlot>
<YieldSlot @name="dialog" @params={{block-params execute cancel message actionName}}>
{{#if confirming }}
{{yield}}
{{/if}}
</YieldSlot>

View File

@ -0,0 +1,19 @@
{{#if item}}
{{#let (if _externalSource _externalSource (service/external-source item)) as |externalSource|}}
{{#if externalSource}}
{{#if (has-block)}}
{{yield
(component 'consul-external-source' item=item _externalSource=externalSource)
}}
{{else}}
<span data-test-external-source={{externalSource}} class="consul-external-source {{externalSource}}">
{{#if (eq externalSource 'aws')}}
<span>Registered via {{uppercase externalSource}}</span>
{{else}}
<span>Registered via {{capitalize externalSource}}</span>
{{/if}}
</span>
{{/if}}
{{/if}}
{{/let}}
{{/if}}

View File

@ -1,6 +1,5 @@
import Component from '@ember/component';
export default Component.extend({
tagName: 'dl',
classNames: ['tag-list'],
tagName: '',
});

View File

@ -0,0 +1,123 @@
<form onsubmit={{action 'submit' _item}}>
<fieldset>
<div role="group">
<fieldset>
<h2>Source</h2>
<label data-test-source-element class="type-text{{if _item.error.SourceName ' has-error'}}">
<span>Source Service</span>
<PowerSelectWithCreate
@options={{_services}}
@searchField="Name"
@selected={{SourceName}}
@searchPlaceholder="Type service name"
@buildSuggestion={{action "createNewLabel" "Use a Consul Service called '{{term}}'"}}
@showCreateWhen={{action "isUnique"}}
@onCreate={{action "change" "SourceName"}}
@onChange={{action "change" "SourceName"}} as |service|>
{{#if (eq service.Name '*') }}
* (All Services)
{{else}}
{{service.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>Search for an existing service, or enter any Service name.</em>
</label>
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
<label data-test-source-nspace class="type-text{{if _item.error.SourceNS ' has-error'}}">
<span>Source Namespace</span>
<PowerSelectWithCreate
@options={{_nspaces}}
@searchField="Name"
@selected={{SourceNS}}
@searchPlaceholder="Type namespace name"
@buildSuggestion={{action "createNewLabel" "Use a Consul Namespace called '{{term}}'"}}
@showCreateWhen={{action "isUnique"}}
@onCreate={{action "change" "SourceNS"}}
@onChange={{action "change" "SourceNS"}} as |nspace|>
{{#if (eq nspace.Name '*') }}
* (All Namespaces)
{{else}}
{{nspace.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>Search for an existing namespace, or enter any Namespace name.</em>
</label>
{{/if}}
</fieldset>
<fieldset>
<h2>Destination</h2>
<label data-test-destination-element class="type-text{{if _item.error.DestinationName ' has-error'}}">
<span>Destination Service</span>
<PowerSelectWithCreate
@options={{_services}}
@searchField="Name"
@selected={{DestinationName}}
@searchPlaceholder="Type service name"
@buildSuggestion={{action "createNewLabel" "Use a Consul Service called '{{term}}'"}}
@showCreateWhen={{action "isUnique"}}
@onCreate={{action "change" "DestinationName"}}
@onChange={{action "change" "DestinationName"}} as |service|>
{{#if (eq service.Name '*') }}
* (All Services)
{{else}}
{{service.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>Search for an existing service, or enter any Service name.</em>
</label>
{{#if (env 'CONSUL_NSPACES_ENABLED')}}
<label data-test-destination-nspace class="type-text{{if _item.error.DestinationNS ' has-error'}}">
<span>Destination Namespace</span>
<PowerSelectWithCreate
@options={{_nspaces}}
@searchField="Name"
@selected={{DestinationNS}}
@searchPlaceholder="Type namespace name"
@buildSuggestion={{action "createNewLabel" "Use a future Consul Namespace called '{{term}}'"}}
@showCreateWhen={{action "isUnique"}}
@onCreate={{action "change" "DestinationNS"}}
@onChange={{action "change" "DestinationNS"}} as |nspace|>
{{#if (eq nspace.Name '*') }}
* (All Namespaces)
{{else}}
{{nspace.Name}}
{{/if}}
</PowerSelectWithCreate>
<em>For the destination, you may choose any namespace for which you have access.</em>
</label>
{{/if}}
</fieldset>
</div>
<div role="radiogroup" class={{if _item.error.Action ' has-error'}}>
{{#each (array 'allow' 'deny') as |intent|}}
<label>
<span>{{ capitalize intent }}</span>
<input type="radio" name="Action" value="{{intent}}" checked={{if (eq _item.Action intent) 'checked'}} onchange={{ action 'change' }}/>
</label>
{{/each}}
</div>
<label class="type-text{{if _item.error.Description ' has-error'}}">
<span>Description (Optional)</span>
<input type="text" name="Description" value="{{_item.Description}}" placeholder="Description (Optional)" onchange={{action 'change'}} />
</label>
</fieldset>
<div>
{{#if _item.isNew }}
<button type="submit" disabled={{if (or _item.isPristine _item.isInvalid) 'disabled'}}>Save</button>
{{ else }}
<button type="submit" disabled={{if _item.isInvalid 'disabled'}}>Save</button>
{{/if}}
<button type="reset" onclick={{action oncancel _item}}>Cancel</button>
{{# if (and _item.ID (not-eq _item.ID 'anonymous')) }}
<ConfirmationDialog @message="Are you sure you want to delete this Intention?">
<BlockSlot @name="action" as |confirm|>
<button data-test-delete type="button" class="type-delete" {{action confirm ondelete _item}}>Delete</button>
</BlockSlot>
<BlockSlot @name="dialog" as |execute cancel message|>
<DeleteConfirmation @message={{message}} @execute={{execute}} @cancel={{cancel}} />
</BlockSlot>
</ConfirmationDialog>
{{/if}}
</div>
</form>

View File

@ -0,0 +1,113 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { setProperties, set, get } from '@ember/object';
import { assert } from '@ember/debug';
export default Component.extend({
tagName: '',
dom: service('dom'),
builder: service('form'),
init: function() {
this._super(...arguments);
this.form = this.builder.form('intention');
},
didReceiveAttrs: function() {
this._super(...arguments);
if (this.item && this.services && this.nspaces) {
let services = this.services || [];
let nspaces = this.nspaces || [];
let source = services.findBy('Name', this.item.SourceName);
if (!source) {
source = { Name: this.item.SourceName };
services = [source].concat(services);
}
let destination = services.findBy('Name', this.item.DestinationName);
if (!destination) {
destination = { Name: this.item.DestinationName };
services = [destination].concat(services);
}
let sourceNS = nspaces.findBy('Name', this.item.SourceNS);
if (!sourceNS) {
sourceNS = { Name: this.item.SourceNS };
nspaces = [sourceNS].concat(nspaces);
}
let destinationNS = this.nspaces.findBy('Name', this.item.DestinationNS);
if (!destinationNS) {
destinationNS = { Name: this.item.DestinationNS };
nspaces = [destinationNS].concat(nspaces);
}
// TODO: Use this.{item,services} when we have this.args
setProperties(this, {
_item: this.form.setData(this.item).getData(),
_services: services,
_nspaces: nspaces,
SourceName: source,
DestinationName: destination,
SourceNS: sourceNS,
DestinationNS: destinationNS,
});
} else {
assert('@item, @services and @nspaces are required arguments', false);
}
},
actions: {
createNewLabel: function(template, term) {
return template.replace(/{{term}}/g, term);
},
isUnique: function(term) {
return !this._services.findBy('Name', term);
},
submit: function(item, e) {
e.preventDefault();
this.onsubmit(...arguments);
},
change: function(e, value, item) {
const event = this.dom.normalizeEvent(e, value);
const form = this.form;
const target = event.target;
let name, selected, match;
switch (target.name) {
case 'SourceName':
case 'DestinationName':
case 'SourceNS':
case 'DestinationNS':
name = selected = target.value;
// Names can be selected Service EmberObjects or typed in strings
// if its not a string, use the `Name` from the Service EmberObject
if (typeof name !== 'string') {
name = get(target.value, 'Name');
}
// mutate the value with the string name
// which will be handled by the form
target.value = name;
// these are 'non-form' variables so not on `item`
// these variables also exist in the template so we know
// the current selection
// basically the difference between
// `item.DestinationName` and just `DestinationName`
// see if the name is already in the list
match = this._services.filterBy('Name', name);
if (match.length === 0) {
// if its not make a new 'fake' Service that doesn't exist yet
// and add it to the possible services to make an intention between
selected = { Name: name };
switch (target.name) {
case 'SourceName':
case 'DestinationName':
set(this, '_services', [selected].concat(this._services.toArray()));
break;
case 'SourceNS':
case 'DestinationNS':
set(this, '_nspaces', [selected].concat(this._nspaces.toArray()));
break;
}
}
set(this, target.name, selected);
break;
}
form.handleEvent(event);
},
},
});

View File

@ -0,0 +1,73 @@
<TabularCollection class="consul-intention-list" @items={{items}} as |item index|>
<BlockSlot @name="header">
<th>Source</th>
<th>&nbsp;</th>
<th>Destination</th>
<th>Precedence</th>
</BlockSlot>
<BlockSlot @name="row">
<td class="source" data-test-intention={{item.ID}}>
<a href={{href-to 'dc.intentions.edit' item.ID}} data-test-intention-source={{item.SourceName}}>
{{#if (eq item.SourceName '*') }}
All Services (*)
{{else}}
{{item.SourceName}}
{{/if}}
{{! TODO: slugify }}
<em class={{concat 'nspace-' (or item.SourceNS 'default')}}>{{or item.SourceNS 'default'}}</em>
</a>
</td>
<td class="intent-{{item.Action}}" data-test-intention-action="{{item.Action}}">
<strong>{{item.Action}}</strong>
</td>
<td class="destination" data-test-intention-destination="{{item.DestinationName}}">
<span>
{{#if (eq item.DestinationName '*') }}
All Services (*)
{{else}}
{{item.DestinationName}}
{{/if}}
{{! TODO: slugify }}
<em class={{concat 'nspace-' (or item.DestinationNS 'default')}}>{{or item.DestinationNS 'default'}}</em>
</span>
</td>
<td class="precedence">
{{item.Precedence}}
</td>
</BlockSlot>
<BlockSlot @name="actions" as |index change checked|>
<PopoverMenu @expanded={{if (eq checked index) true false}} @onchange={{action change index}} @keyboardAccess={{false}}>
<BlockSlot @name="trigger">
More
</BlockSlot>
<BlockSlot @name="menu" as |confirm send keypressClick change|>
<li role="none">
<a role="menuitem" tabindex="-1" href={{href-to 'dc.intentions.edit' item.ID}}>Edit</a>
</li>
<li role="none" class="dangerous">
<label for={{confirm}} role="menuitem" tabindex="-1" onkeypress={{keypressClick}} data-test-delete>Delete</label>
<div role="menu">
<div class="confirmation-alert warning">
<div>
<header>
Confirm Delete
</header>
<p>
Are you sure you want to delete this intention?
</p>
</div>
<ul>
<li class="dangerous">
<button tabindex="-1" type="button" class="type-delete" onclick={{queue (action change) (action ondelete item)}}>Delete</button>
</li>
<li>
<label for={{confirm}}>Cancel</label>
</li>
</ul>
</div>
</div>
</li>
</BlockSlot>
</PopoverMenu>
</BlockSlot>
</TabularCollection>

View File

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

View File

@ -0,0 +1,11 @@
{{#if item.Kind}}
{{#if (has-block)}}
{{yield
(component 'consul-kind' item=item)
}}
{{else}}
<span data-test-kind={{item.Kind}} class="consul-kind gateway">
<span>{{titleize (humanize item.Kind)}}</span>
</span>
{{/if}}
{{/if}}

View File

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

View File

@ -0,0 +1,16 @@
## ConsulLoader
`<ConsulLoader />`
Simple template-only component to show the circulr animated Consul loader animation.
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -51,4 +51,3 @@
<circle r="9" cx="22" cy="22" style="transform-origin: 22px 22px" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

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

View File

@ -0,0 +1,27 @@
## ConsulMetadataList
`<ConsulMetadataList @items={{meta}} />`
A presentational component for presenting Consul Metadata
### Arguments
| Argument/Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `array` | | A an array of entries or `[key, value]` pairs as returned by `Object.entries()` |
### Example
The following example shows how to construct the required structure from the
Consul API using a `object-entries` helper.
```handlebars
<ConsulMetadataList @items={{object-entries item.Meta}} />
```
### See
- [Component Source Code](./index.js)
- [TemplateSource Code](./index.hbs)
---

View File

@ -0,0 +1,19 @@
<TabularCollection
data-test-metadata
@items={{items}} as |item index|
>
<BlockSlot @name="header">
<th>Key</th>
<th>Value</th>
</BlockSlot>
<BlockSlot @name="row">
<td>
<span>
{{object-at 0 item}}
</span>
</td>
<td>
<span>{{object-at 1 item}}</span>
</td>
</BlockSlot>
</TabularCollection>

View File

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

View File

@ -0,0 +1,47 @@
{{yield}}
{{#if (gt items.length 0)}}
<ListCollection @cellHeight={{73}} @items={{items}} class="consul-service-instance-list" as |item index|>
<a href={{href-to routeName item.Service.Service item.Node.Node (or item.Service.ID item.Service.Service)}}>
{{item.Service.ID}}
</a>
<ul>
<ConsulExternalSource @item={{item.Service}} as |ExternalSource|>
<li>
<ExternalSource />
</li>
</ConsulExternalSource>
{{#with (reject-by 'ServiceID' '' item.Checks) as |checks|}}
<li class={{service/instance-checks checks}}>
{{checks.length}} service checks
</li>
{{/with}}
{{#with (filter-by 'ServiceID' '' item.Checks) as |checks|}}
<li class={{service/instance-checks checks}}>
{{checks.length}} node checks
</li>
{{/with}}
{{#if (get proxies item.Service.ID)}}
<li class="proxy">
connected with proxy
</li>
{{/if}}
{{#if (gt item.Node.Node.length 0)}}
<li class="node">
<a href={{href-to 'dc.nodes.show' item.Node.Node}}>{{item.Node.Node}}</a>
</li>
{{/if}}
<li class="address" data-test-address>
{{#if (not-eq item.Service.Address '')}}
{{item.Service.Address}}:{{item.Service.Port}}
{{else}}
{{item.Node.Address}}:{{item.Service.Port}}
{{/if}}
</li>
<TagList @item={{item.Service}} as |Tags|>
<li>
<Tags />
</li>
</TagList>
</ul>
</ListCollection>
{{/if}}

View File

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

View File

@ -0,0 +1,35 @@
{{yield}}
{{#if (gt items.length 0)}}
<ListCollection @cellHeight={{73}} @items={{items}} class="consul-service-list" as |item index|>
<a data-test-service-name href={{href-to routeName item.Name}} class={{service/health-checks item}}>
{{item.Name}}
</a>
<ul>
<ConsulKind @item={{item}} as |Kind|>
<li>
<Kind />
</li>
</ConsulKind>
<ConsulExternalSource @item={{item}} as |ExternalSource|>
<li>
<ExternalSource />
</li>
</ConsulExternalSource>
{{#if (not-eq item.InstanceCount 0)}}
<li>
{{format-number item.InstanceCount}} {{pluralize item.InstanceCount 'Instance' without-count=true}}
</li>
{{/if}}
{{#if (get proxies item.Name)}}
<li data-test-proxy class="proxy">
connected with proxy
</li>
{{/if}}
<TagList @item={{item}} as |Tags|>
<li>
<Tags />
</li>
</TagList>
</ul>
</ListCollection>
{{/if}}

View File

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

View File

@ -1,38 +0,0 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import WithListeners from 'consul-ui/mixins/with-listeners';
export default Component.extend(WithListeners, {
clipboard: service('clipboard/os'),
tagName: 'button',
classNames: ['copy-btn'],
buttonType: 'button',
disabled: false,
error: function() {},
success: function() {},
attributeBindings: [
'clipboardText:data-clipboard-text',
'clipboardTarget:data-clipboard-target',
'clipboardAction:data-clipboard-action',
'buttonType:type',
'disabled',
'aria-label',
'title',
],
delegateClickEvent: true,
didInsertElement: function() {
this._super(...arguments);
const clipboard = this.clipboard.execute(
this.delegateClickEvent ? `#${this.elementId}` : this.element
);
['success', 'error'].map(event => {
return this.listen(clipboard, event, () => {
if (!this.disabled) {
this[event](...arguments);
}
});
});
},
});

View File

@ -0,0 +1,32 @@
## CopyButton
```handlebars
{{! inline }}
<CopyButton
@value={{stringToCopy}}
@name="Thing"
/>
<CopyButton
@value={{stringToCopy}}
@name="Thing"
>
Copy me!
</CopyButton>
```
### Arguments
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `String` | | The string to be copied to the clipboard on click |
| `name` | `String` | | The 'Name' of the string to be copied. Mainly used for giving feedback to the user |
This component renders a simple button, when clicked copies the value (the `@value` attribute) to the users clipboard. A simple piece of feedback is given to the user in the form of a tooltip. When used inline an empty button is rendered.
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,17 @@
<FeedbackDialog @type="inline">
<BlockSlot @name="action" as |success error|>
<Ref @target={{this}} @name="success" @value={{success}} />
<Ref @target={{this}} @name="error" @value={{error}} />
<button id={{guid}} title={{concat "Copy " name " to the clipboard"}} ...attributes type="button" class="copy-btn" data-clipboard-text={{value}}>{{~yield~}}</button>
</BlockSlot>
<BlockSlot @name="success" as |transition|>
<p class={{transition}}>
Copied {{name}}!
</p>
</BlockSlot>
<BlockSlot @name="error" as |transition|>
<p class={{transition}}>
Sorry, something went wrong!
</p>
</BlockSlot>
</FeedbackDialog>

View File

@ -0,0 +1,29 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
export default Component.extend({
clipboard: service('clipboard/os'),
dom: service('dom'),
tagName: '',
init: function() {
this._super(...arguments);
this.guid = this.dom.guid(this);
this._listeners = this.dom.listeners();
},
willDestroyElement: function() {
this._super(...arguments);
this._listeners.remove();
},
didInsertElement: function() {
this._super(...arguments);
const component = this;
this._listeners.add(this.clipboard.execute(`#${this.guid}`), {
success: function() {
component.success(...arguments);
},
error: function() {
component.error(...arguments);
},
});
},
});

View File

@ -0,0 +1,62 @@
## DataSink
```handlebars
<DataSink
@sink="/dc/nspace/intentions/{{intentions.uid}}"
@onchange={{action (mut items) value="data"}}
@onerror={{action (mut error) value="error"}}
as |api|
></DataSink>
```
### Arguments
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `sink` | `String` | | The location of the sink, this should map to a string based URI |
| `data` | `Object` | | The data to be saved to the current instance, null or an empty string means remove |
| `onchange` | `Function` | | The action to fire when the data has arrived to the sink. Emits an Event-like object with a `data` property containing the data, if the data was deleted this is `undefined`. |
| `onerror` | `Function` | | The action to fire when an error occurs. Emits ErrorEvent object with an `error` property containing the Error. |
### Methods/Actions/api
| Method/Action | Description |
| --- | --- |
| `open` | Manually add or remove fom the data sink |
The component takes a `sink` or an identifier (a uri) for the location of a sink and then emits `onchange` events whenever that data has been arrived to the sink (whether persisted or removed). If an error occurs whilst listening for data changes, an `onerror` event is emitted.
Behind the scenes in the Consul UI we map URIs back to our `ember-data` backed `Repositories` meaning we can essentially redesign the URIs used for our data to more closely fit our needs. For example we currently require that **all** HTTP API URIs begin with `/dc/nspace/` values whether they require them or not.
`DataSink` is not just restricted to HTTP API data, and can be configured to listen for data changes using a variety of methods and sources. For example we have also configured `DataSink` to send data to `LocalStorage` using the `settings://` pseudo-protocol in the URI (See examples below).
### Examples
```handlebars
<DataSink @src="/dc/nspace/intentions/{{intention.uid}}"
@onchange={{action (mut item) value="data"}}
@onerror={{action (mut error) value="error"}}
as |api|
>
<button type="button" onclick={{action api.open (hash Name="New Name")}}>Create/Update</button>
<button type="button" onclick={{action api.open null}}>Delete</button>
</DataSink>
{{item.Name}}
```
```handlebars
<DataSink @src="/dc/nspace/intentions/{{intention.uid}}"
@data=(hash Name="New Name")
@onchange={{action (mut item) value="data"}}
@onerror={{action (mut error) value="error"}}
></DataSink>
{{item.Name}}
```
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,4 @@
{{yield (hash
open=(action 'open')
state=state
)}}

View File

@ -0,0 +1,105 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { set, get, computed } from '@ember/object';
import { once } from 'consul-ui/utils/dom/event-source';
export default Component.extend({
tagName: '',
service: service('data-sink/service'),
dom: service('dom'),
logger: service('logger'),
onchange: function(e) {},
onerror: function(e) {},
state: computed('instance', 'instance.{dirtyType,isSaving}', function() {
let id;
const isSaving = get(this, 'instance.isSaving');
const dirtyType = get(this, 'instance.dirtyType');
if (typeof isSaving === 'undefined' && typeof dirtyType === 'undefined') {
id = 'idle';
} else {
switch (dirtyType) {
case 'created':
id = isSaving ? 'creating' : 'create';
break;
case 'updated':
id = isSaving ? 'updating' : 'update';
break;
case 'deleted':
case undefined:
id = isSaving ? 'removing' : 'remove';
break;
}
id = `active.${id}`;
}
return {
matches: name => id.indexOf(name) !== -1,
};
}),
init: function() {
this._super(...arguments);
this._listeners = this.dom.listeners();
},
willDestroy: function() {
this._super(...arguments);
this._listeners.remove();
},
source: function(cb) {
const source = once(cb);
const error = err => {
set(this, 'instance', undefined);
try {
this.onerror(err);
this.logger.execute(err);
} catch (err) {
this.logger.execute(err);
}
};
this._listeners.add(source, {
message: e => {
try {
set(this, 'instance', undefined);
this.onchange(e);
} catch (err) {
error(err);
}
},
error: e => error(e),
});
return source;
},
didInsertElement: function() {
this._super(...arguments);
if (typeof this.data !== 'undefined') {
this.actions.open.apply(this, [this.data]);
}
},
persist: function(data, instance) {
set(this, 'instance', this.service.prepare(this.sink, data, instance));
this.source(() => this.service.persist(this.sink, this.instance));
},
remove: function(instance) {
set(this, 'instance', this.service.prepare(this.sink, null, instance));
this.source(() => this.service.remove(this.sink, this.instance));
},
actions: {
open: function(data, instance) {
if (instance instanceof Event) {
instance = undefined;
}
if (typeof data === 'undefined') {
throw new Error('You must specify data to save, or null to remove');
}
// potentially allow {} and "" as 'remove' flags
if (data === null || data === '') {
this.remove(instance);
} else {
this.persist(data, instance);
}
},
},
});

View File

@ -0,0 +1,61 @@
## DataSource
```handlebars
<DataSource
@src="/dc/nspace/services"
@loading="eager"
@onchange={{action (mut items) value="data"}}
@onerror={{action (mut error) value="error"}}
/>
```
### Arguments
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `src` | `String` | | The source to subscribe to updates to, this should map to a string based URI |
| `loading` | `String` | eager | Allows the browser to defer loading offscreen DataSources (`eager\|lazy`). Setting to `lazy` only loads the data when the DataSource is visible in the DOM (inc. `display: none\|block;`) |
| `onchange` | `Function` | | The action to fire when the data changes. Emits an Event-like object with a `data` property containing the data. |
| `onerror` | `Function` | | The action to fire when an error occurs. Emits ErrorEvent object with an `error` property containing the Error. |
The component takes a `src` or an identifier (a uri) for some data and then emits `onchange` events whenever that data changes. If an error occurs whilst listening for data changes, an `onerror` event is emitted.
Setting `@loading="lazy"` uses `IntersectionObserver` to activate/deactive event emitting until the `<DataSource />` element is displayed in the DOM. This means you can use CSS `display: none|block;` to control the loading and stopping loading of data for usage with CSS based tabs and such-like.
Consuls HTTP API DataSources use Consul's blocking query support for live updating of data.
Behind the scenes in the Consul UI we map URIs back to our `ember-data` backed `Repositories` meaning we can essentially redesign the URIs used for our data to more closely fit our needs. For example we currently require that **all** HTTP API URIs begin with `/dc/nspace/` values whether they require them or not.
`DataSource` is not just restricted to HTTP API data, and can be configured to listen for data changes using a variety of methods and sources. For example we have also configured `DataSource` to listen to `LocalStorage` changes using the `settings://` pseudo-protocol in the URI (See examples below).
### Example
Straightforward usage can use `mut` to easily update data within a template
```handlebars
{{! listen for HTTP API changes}}
<DataSource @src="/dc/nspace/services"
@onchange={{action (mut items) value="data"}}
@onerror={{action (mut error) value="error"}}
/>
{{! the value of items will change whenever the data changes}}
{{#each items as |item|}}
{{item.Name}} {{! < Prints the item name }}
{{/each}}
{{! listen for Settings (local storage) changes}}
<DataSource @src="settings://consul:token"
@onchange={{action (mut token) value="data"}}
@onerror={{action (mut error) value="error"}}
/>
{{! the value of token will change whenever the data changes}}
{{token.AccessorID}} {{! < Prints the token AccessorID }}
```
### See
- [Component Source Code](./index.js)
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,4 @@
{{#if (eq loading "lazy")}}
{{! in order to use intersection observer we need a DOM element on the page}}
<data id={{guid}} aria-hidden="true" style="width: 0;height: 0;font-size: 0;padding: 0;margin: 0;" />
{{/if}}

View File

@ -0,0 +1,129 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { set } from '@ember/object';
import { schedule } from '@ember/runloop';
/**
* Utility function to set, but actually replace if we should replace
* then call a function on the thing to be replaced (usually a clean up function)
*
* @param obj - target object with the property to replace
* @param prop {string} - property to replace on the target object
* @param value - value to use for replacement
* @param destroy {(prev: any, value: any) => any} - teardown function
*/
const replace = function(
obj,
prop,
value,
destroy = (prev = null, value) => (typeof prev === 'function' ? prev() : null)
) {
const prev = obj[prop];
if (prev !== value) {
destroy(prev, value);
}
return set(obj, prop, value);
};
export default Component.extend({
tagName: '',
data: service('data-source/service'),
dom: service('dom'),
logger: service('logger'),
onchange: function(e) {},
onerror: function(e) {},
loading: 'eager',
isIntersecting: false,
init: function() {
this._super(...arguments);
this._listeners = this.dom.listeners();
this._lazyListeners = this.dom.listeners();
this.guid = this.dom.guid(this);
},
willDestroy: function() {
this.actions.close.apply(this);
this._listeners.remove();
this._lazyListeners.remove();
},
didInsertElement: function() {
this._super(...arguments);
if (this.loading === 'lazy') {
this._lazyListeners.add(
this.dom.isInViewport(this.dom.element(`#${this.guid}`), inViewport => {
set(this, 'isIntersecting', inViewport);
if (!this.isIntersecting) {
this.actions.close.bind(this)();
} else {
this.actions.open.bind(this)();
}
})
);
}
},
didReceiveAttrs: function() {
this._super(...arguments);
if (this.loading === 'eager') {
this._lazyListeners.remove();
}
if (this.loading === 'eager' || this.isIntersecting) {
this.actions.open.bind(this)();
}
},
actions: {
// keep this argumentless
open: function() {
// get a new source and replace the old one, cleaning up as we go
const source = replace(this, 'source', this.data.open(this.src, this), (prev, source) => {
// Makes sure any previous source (if different) is ALWAYS closed
this.data.close(prev, this);
});
const error = err => {
try {
this.onerror(err);
this.logger.execute(err);
} catch (err) {
this.logger.execute(err);
}
};
// set up the listeners (which auto cleanup on component destruction)
const remove = this._listeners.add(this.source, {
message: e => {
try {
this.onchange(e);
} catch (err) {
error(err);
}
},
error: e => error(e),
});
replace(this, '_remove', remove);
// dispatch the current data of the source if we have any
if (typeof source.getCurrentEvent === 'function') {
const currentEvent = source.getCurrentEvent();
if (currentEvent) {
schedule('afterRender', () => {
try {
this.onchange(currentEvent);
} catch (err) {
error(err);
}
});
}
}
},
// keep this argumentless
close: function() {
if (typeof this.source !== 'undefined') {
this.data.close(this.source, this);
replace(this, '_remove', undefined);
set(this, 'source', undefined);
}
},
},
});

View File

@ -4,11 +4,11 @@
{{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);
background-color: var(--white);
border: var(--decor-border-100);
border-radius: var(--decor-radius-200);
border-color: var(--gray-500);
box-shadow: var(--decor-elevation-600);
}
{{/if}}
{{#if selected.edges }}
@ -28,7 +28,7 @@
</header>
<div role="group">
{{#each routes as |item|}}
{{route-card item=item onclick=(action 'click')}}
<RouteCard @item={{item}} @onclick={{action "click"}} />
{{/each}}
</div>
</div>
@ -43,7 +43,7 @@
</header>
<div role="group">
{{#each (sort-by 'Name' splitters) as |item|}}
{{splitter-card item=item onclick=(action 'click')}}
<SplitterCard @item={{item}} @onclick={{action "click"}} />
{{/each}}
</div>
</div>
@ -58,7 +58,7 @@
</header>
<div role="group">
{{#each (sort-by 'Name' resolvers) as |item|}}
{{resolver-card item=item onclick=(action 'click')}}
<ResolverCard @item={{item}} @onclick={{action "click"}} />
{{/each}}
</div>
</div>

View File

@ -1,7 +1,6 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { set, get, computed } from '@ember/object';
import { next } from '@ember/runloop';
import {
createRoute,
@ -147,24 +146,21 @@ export default Component.extend({
// the developer access to the mouse event therefore we just use JS to add our events
// revisit this post Octane
addPathListeners: function() {
// TODO: Figure out if we can remove this next
next(() => {
this._listeners.remove();
this._listeners.add(this.dom.document(), {
click: e => {
// all route/splitter/resolver components currently
// have classes that end in '-card'
if (!this.dom.closest('[class$="-card"]', e.target)) {
set(this, 'active', false);
set(this, 'selectedId', '');
}
},
});
[...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]),
});
this._listeners.remove();
this._listeners.add(this.dom.document(), {
click: e => {
// all route/splitter/resolver components currently
// have classes that end in '-card'
if (!this.dom.closest('[class$="-card"]', e.target)) {
set(this, 'active', false);
set(this, 'selectedId', '');
}
},
});
[...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

View File

@ -9,10 +9,11 @@ export default Component.extend({
},
didInsertElement: function() {
this._super(...arguments);
this.buffer.add(this.getBufferName(), this.element);
this._element = this.buffer.add(this.getBufferName(), this.element);
},
didDestroyElement: function() {
this._super(...arguments);
this.buffer.remove(this.getBufferName());
this.buffer.remove(this.getBufferName(), this._element);
this._element = null;
},
});

View File

@ -0,0 +1,21 @@
{{yield}}
<div class="empty-state" ...attributes>
<header>
{{#yield-slot name="header"}}
{{yield}}
{{/yield-slot}}
{{#yield-slot name="subheader"}}
{{yield}}
{{/yield-slot}}
</header>
<p>
{{#yield-slot name="body"}}
{{yield}}
{{/yield-slot}}
</p>
{{#yield-slot name="actions"}}
<ul>
{{yield}}
</ul>
{{/yield-slot}}
</div>

View File

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

View File

@ -0,0 +1,9 @@
{{yield}}
{{#if (eq state 'success') }}
<YieldSlot @name="success" @params={{block-params transition}}>{{yield}}</YieldSlot>
{{else if (eq state 'error') }}
<YieldSlot @name="error" @params={{block-params transition}}>{{yield}}</YieldSlot>
{{/if}}
{{#if (or permanent (eq state 'ready')) }}
<YieldSlot @name="action" @params={{block-params success error}}>{{yield}}{{message}}</YieldSlot>
{{/if}}

View File

@ -1,7 +1,6 @@
import Component from '@ember/component';
import { set } from '@ember/object';
import { inject as service } from '@ember/service';
import { Promise } from 'rsvp';
import SlotsMixin from 'block-slots';
const STATE_READY = 'ready';

View File

@ -6,6 +6,7 @@ import WithListeners from 'consul-ui/mixins/with-listeners';
// match anything that isn't a [ or ] into multiple groups
const propRe = /([^[\]])+/g;
export default Component.extend(WithListeners, SlotsMixin, {
tagName: '',
onreset: function() {},
onchange: function() {},
onerror: function() {},

View File

@ -0,0 +1,6 @@
<EmberNativeScrollable @tagName="ul" @content-size={{_contentSize}} @scroll-left={{_scrollLeft}} @scroll-top={{_scrollTop}} @scrollChange={{action "scrollChange"}} @clientSizeChange={{action "clientSizeChange"}}>
<li></li>
{{~#each _cells as |cell|~}}
<li style={{{cell.style}}}>{{yield cell.item cell.index }}</li>
{{~/each~}}
</EmberNativeScrollable>

View File

@ -12,7 +12,7 @@ export default Component.extend(WithResizing, {
height: 500,
cellHeight: 113,
style: style('getStyle'),
classNames: ['list-collection'],
classNames: ['grid-collection'],
init: function() {
this._super(...arguments);
this.columns = [25, 25, 25, 25];

View File

@ -1,3 +1,4 @@
<ModalLayer />
<header role="banner" data-test-navigation>
<a data-test-main-nav-logo href={{href-to 'index'}}><svg width="28" height="27" xmlns="http://www.w3.org/2000/svg"><title>Consul</title><path d="M13.284 16.178a2.876 2.876 0 1 1-.008-5.751 2.876 2.876 0 0 1 .008 5.75zm5.596-1.547a1.333 1.333 0 1 1 0-2.667 1.333 1.333 0 0 1 0 2.667zm4.853 1.249a1.271 1.271 0 1 1 .027-.107c0 .031 0 .067-.027.107zm-.937-3.436a1.333 1.333 0 1 1 .986-1.595c.033.172.033.348 0 .52-.07.53-.465.96-.986 1.075zm4.72 3.29a1.333 1.333 0 1 1-1.076-1.538 1.333 1.333 0 0 1 1.116 1.417.342.342 0 0 0-.027.12h-.013zm-1.08-3.33a1.333 1.333 0 1 1 1.088-1.524c.014.114.014.229 0 .342a1.333 1.333 0 0 1-1.102 1.182h.014zm-.925 7.925a1.333 1.333 0 1 1 .165-.547c-.01.193-.067.38-.165.547zm-.48-12.191a1.333 1.333 0 1 1 .507-1.814c.14.237.198.514.164.787-.038.438-.289.828-.67 1.045v-.018zM13.333 26.667C5.97 26.667 0 20.697 0 13.333 0 5.97 5.97 0 13.333 0c2.929-.01 5.778.955 8.098 2.742L19.8 4.89a10.667 10.667 0 0 0-17.133 8.444 10.667 10.667 0 0 0 17.137 8.471l1.627 2.13a13.218 13.218 0 0 1-8.098 2.733z" fill="#FFF"/></svg></a>
<input type="checkbox" name="menu" id="main-nav-toggle" onchange={{action 'change'}} />
@ -16,20 +17,25 @@
{{#if (and (eq nspaces.length 1) (not canManageNspaces)) }}
<span data-test-nspace-selected={{nspace.Name}}>{{nspace.Name}}</span>
{{ else }}
{{#popover-menu}}
{{#block-slot name='trigger'}}
<PopoverMenu @position="left">
<BlockSlot @name="trigger">
{{nspace.Name}}
{{/block-slot}}
</BlockSlot>
{{#if (is-href 'dc.nspaces')}}
{{#block-slot name='header'}}
<BlockSlot @name="header">
<p>
Namespaces themselves are not namespaced, so switching will not change the current view.
</p>
{{/block-slot}}
</BlockSlot>
{{/if}}
{{#block-slot name='menu'}}
<BlockSlot @name="menu">
<li role="separator">
Namespaces
<DataSource
@src="/*/*/namespaces"
@onchange={{action (mut nspaces) value="data"}}
@loading="lazy"
/>
</li>
{{#each (reject-by 'DeletedAt' nspaces) as |item|}}
<li role="none" class={{if (eq nspace.Name item.Name) 'is-active'}}>
@ -42,8 +48,8 @@
<a tabindex="-1" role="menuitem" href={{href-to 'dc.nspaces' dc.Name}}>Manage namespaces</a>
</li>
{{/if}}
{{/block-slot}}
{{/popover-menu}}
</BlockSlot>
</PopoverMenu>
{{/if}}
</li>
{{/if}}
@ -51,21 +57,26 @@
{{#if (or (not dcs) (eq dcs.length 1)) }}
<span data-test-datacenter-selected={{dc.Name}}>{{dc.Name}}</span>
{{ else }}
{{#popover-menu}}
{{#block-slot name='trigger'}}
<PopoverMenu @position="left">
<BlockSlot @name="trigger">
{{dc.Name}}
{{/block-slot}}
{{#block-slot name='menu'}}
</BlockSlot>
<BlockSlot @name="menu">
<li role="separator">
Datacenters
<DataSource
@src="/*/*/datacenters"
@onchange={{action (mut dcs) value="data"}}
@loading="lazy"
/>
</li>
{{#each dcs as |item|}}
<li role="none" data-test-datacenter-picker class={{if (eq dc.Name item.Name) 'is-active'}}>
<a tabindex="-1" role="menuitem" href={{href-mut (hash dc=item.Name)}}>{{item.Name}}</a>
</li>
{{/each}}
{{/block-slot}}
{{/popover-menu}}
</BlockSlot>
</PopoverMenu>
{{/if}}
</li>
@ -89,12 +100,79 @@
</nav>
<nav>
<ul>
<li data-test-main-nav-docs>
<a href="{{ env 'CONSUL_DOCS_URL'}}" rel="help noopener noreferrer" target="_blank">Documentation</a>
<li data-test-main-nav-help>
<PopoverMenu @position="right">
<BlockSlot @name="trigger">
Help
</BlockSlot>
<BlockSlot @name="menu" as |id send keypressClick change|>
<li role="none" class="docs-link">
<a tabindex="-1" role="menuitem" href={{env 'CONSUL_DOCS_URL'}} rel="noopener noreferrer" target="_blank" onclick={{change}}>Documentation</a>
</li>
<li role="none" class="learn-link">
<a tabindex="-1" role="menuitem" href={{concat (env 'CONSUL_DOCS_LEARN_URL') '/consul'}} rel="noopener noreferrer" target="_blank" onclick={{change}}>HashiCorp Learn</a>
</li>
<li role="separator"></li>
<li role="none" class="feedback-link">
<a tabindex="-1" role="menuitem" href={{env 'CONSUL_REPO_ISSUES_URL'}} target="_blank" rel="noopener noreferrer" onclick={{change}}>Provide Feedback</a>
</li>
</BlockSlot>
</PopoverMenu>
</li>
<li data-test-main-nav-settings class={{if (is-href 'settings') 'is-active'}}>
<a href={{href-to 'settings'}}>Settings</a>
</li>
{{#if (env 'CONSUL_ACLS_ENABLED')}}
<li data-test-main-nav-auth>
<AuthDialog
@dc={{dc.Name}}
@nspace={{nspace.Name}}
@onchange={{action onchange}} 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'}}>
<BlockSlot @name="header">
<h2>Log in to Consul</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>
</BlockSlot>
<BlockSlot @name="authorized">
<PopoverMenu @position="right">
<BlockSlot @name="trigger">
Logout
</BlockSlot>
<BlockSlot @name="menu">
{{!TODO: It might be nice to use one of our recursive components here}}
{{#if authDialog.token.AccessorID}}
<li role="none">
<AuthProfile />
</li>
<li role="separator"></li>
{{/if}}
<li class="dangerous" role="none">
<button type="button" tabindex="-1" role="menuitem" onclick={{action authDialog.logout}}>Logout</button>
</li>
</BlockSlot>
</PopoverMenu>
</BlockSlot>
{{/let}}
</AuthDialog>
</li>
{{/if}}
</ul>
</nav>
</div>
@ -107,5 +185,4 @@
<p data-test-footer-version>Consul {{env 'CONSUL_VERSION'}}</p>
<a data-test-footer-docs href="{{env 'CONSUL_DOCS_URL'}}" rel="help noopener noreferrer" target="_blank">Documentation</a>
{{{concat '<!-- ' (env 'CONSUL_GIT_SHA') '-->'}}}
</footer>
{{modal-layer}}
</footer>

View File

@ -4,7 +4,9 @@ import { computed } from '@ember/object';
export default Component.extend({
dom: service('dom'),
didInsertElement: function() {
this._super(...arguments);
this.dom.root().classList.remove('template-with-vertical-menu');
},
// TODO: Right now this is the only place where we need permissions
@ -17,6 +19,15 @@ export default Component.extend({
);
}),
actions: {
keypressClick: function(e) {
e.target.dispatchEvent(new MouseEvent('click'));
},
open: function() {
this.authForm.focus();
},
close: function() {
this.authForm.reset();
},
change: function(e) {
const win = this.dom.viewport();
const $root = this.dom.root();

View File

@ -0,0 +1,9 @@
{{#if (and (lt passing 1) (lt warning 1) (lt critical 1) )}}
<span title="No Healthchecks" class="zero">0</span>
{{else}}
<dl class="healthcheck-info">
<HealthcheckStatus @width={{passingWidth}} @name="passing" @value={{passing}} />
<HealthcheckStatus @width={{warningWidth}} @name="warning" @value={{warning}} />
<HealthcheckStatus @width={{criticalWidth}} @name="critical" @value={{critical}} />
</dl>
{{/if}}

View File

@ -1,18 +1,19 @@
<ul data-test-healthchecks>
<ul>
{{#each (sort-by (action 'sortChecksByImportance') items) as |item| }}
{{! TODO: this component and its child should be moved to a single component }}
{{#healthcheck-output
data-test-node-healthcheck=item.Name
class=item.Status
tagName='li'
}}
{{#block-slot name='header'}}
<HealthcheckOutput class={{item.Status}} @tagName="li">
<BlockSlot @name="header">
<h3>{{item.Name}}</h3>
{{/block-slot}}
{{#block-slot name='content'}}
</BlockSlot>
<BlockSlot @name="content">
<dl>
{{#if (eq item.ServiceName "")}}
<dt>NodeName</dt>
<dd>{{item.Node}}</dd>
{{else}}
<dt>ServiceName</dt>
<dd>{{or item.ServiceName '-'}}</dd>
<dd>{{item.ServiceName}}</dd>
{{/if}}
</dl>
<dl>
<dt>CheckID</dt>
@ -36,25 +37,11 @@
<dt>Output</dt>
<dd>
<pre><code>{{item.Output}}</code></pre>
{{#feedback-dialog type='inline'}}
{{#block-slot name='action' as |success error|}}
{{copy-button success=(action success) error=(action error) clipboardText=item.Output title='copy output to clipboard'}}
{{/block-slot}}
{{#block-slot name='success' as |transition|}}
<p class={{transition}}>
Copied output!
</p>
{{/block-slot}}
{{#block-slot name='error' as |transition|}}
<p class={{transition}}>
Sorry, something went wrong!
</p>
{{/block-slot}}
{{/feedback-dialog}}
<CopyButton @value={{item.Output}} @name="output" />
</dd>
{{/if}}
</dl>
{{/block-slot}}
{{/healthcheck-output}}
</BlockSlot>
</HealthcheckOutput>
{{/each}}
</ul>

Some files were not shown because too many files have changed in this diff Show More