* Move settings to use the same service/route API as the rest of the app

* Put some ideas down for unit testing on adapters

* Favour `Model` over `Entity`

* Move away from using `reopen` to using Mixins

* Amend messages, comment/document some usage

* Make sure the returns are consistent in normalizePayload, also

Add some todo's in to remind me to think consider this further at a
later date. For example, is normalizePayload to be a hook or an
overridable method

* Start stripping back the HTML to semantics

* Use a variable rather than chaining

* Remove unused helpers

* Start picking through the new designs, start with listing pages

* First draft HTML for every page

* Making progress on the CSS

* Keep plugging away at the catalog css

* Looking at scrolling

* Wire up filtering

* Sort out filter counting, more or less done a few outstanding

* Start knocking the forms into shape

* Add in codemirror

* Keep moving forwards with the form like layouts

* Start looking at ACL editing page, add footer in

* Pull the filters back in, look at an autoresizer for scroll views

* First draft toggles

* 2nd draft healthcheck icons

* Tweak node healthcheck icons

* Looking at healthcheck detail icons

* Tweak the filter-bar and add selections to the in content tabs

* Add ACL create, pill-like acl type highlight

* Tweaking the main nav some more

* Working on the filter-bar and freetext-filter

* Masonry layout

* Stick with `checks` instead of healthy/unhealthy

* Fix up the filter numbers/counts

* Use the thead for a measure

* First draft tomography back in

* First draft DC dropdown

* Add a temporary create buttong to kv's

* Move KV and ACL to use a create page

* Move tags

* Run through old tests

* Injectable server

* Start adding test attributes

* Add some page objects

* More test attributes and pages

* Acl filter objects

* Add a page.. page object

* Clickable items in lists

* Add rest/spread babel plugin, remove mirage for now

* Add fix for ember-collection

* Keep track of acl filters

* ember-cli-page-object

* ember-test-selectors

* ui: update version of ui compile deps

* Update static assets

* Centralize radiogroup helper

* Rejig KV's and begin to clean it up

* Work around lack of Tags for the moment..

* Some little css tweaks and start to remove possibles

* Working on the dc page and incidentals

1. Sort the datacenter-picker list
2. Add a selected state to the datacenter-picker
3. Make dc an {Name: dc}
4. Add an env helper to get to 'env vars' from within templates

* Click outside stuff for the datacenter-picker, is-active on nav

* Make sure the dropdown CTA can be active

* Bump ember add pluralize helper

* Little try at sass based custom queries

* Rejig tablular collection so it deals with resizing, actions

1. WIP: start building actions dropdowns
2. Move tabular collection to deal with resizing to rule out differences

* First draft actions dropdowns

* Add ports, selectable IP's

* Flash messages, plus general cleanup/consistency

1. Add ember-cli-flash for flash messages
2. Move everything to get() instead of item.get
3. Spotted a few things that weren't consistent

* DOn't go lower than zero

* First draft vertical menu

* Missed a get, tweak dropmenu tick

* Big cleanup

1. this.get(), this.set() > get(), set()
2. assign > {...{}, ...{}}
3. Seperator > separator

* WIP: settings

* Moved things into a ui-v2 folder

* Decide on a way to do the settings page whilst maintaining the url + dc's

* Start some error pages

* Remove base64 polyfill

* Tie in settings, fix atob bug, tweak layout css

* Centralize confirmations into a component

* Allow switching between the old and new UI with the CONSUL_UI_BETA env var

Currently all the assets are packaged into a single AssetFS and a prefix is configured to switch between the two.

* Attempt at some updates to integrate the v2 ui build into the main infrastructure

* Add redirect to index.html for unknown paths

* Allow redictor to /index.html for new ui when using -ui-dir

* Take ACLs to the correct place on save

* First pass breadcrumbs

* Remove datacenter selector on the index page

* Tweak overall layout

* Make buttons 'resets'

* Tweak last DC stuff

* Validations plus kv keyname viewing tweaks

* Pull sessions back in

* Tweak the env vars to be more reusable

* Move isAnon to the view

* No items and disabled acl css

* ACL and KV details

1. Unauthorized page
2. Make sure the ACL is always selected when it needs it
3. Check record deletion with a changeset

* Few more acl tweaks/corrections

* Add no items view to node > services

* Tags for node > services

* Make sure we have tags

* Fix up the labels on the tomography graph

* Add node link (agent) to kv sessions

* Duplicate up `create` for KV 'root creation'

* Safety check for health checks

* Fix up the grids

* Truncate td a's, fix kv columns

* Watch for spaces in KV id's

* Move actions to their own mixins for now at least

* Link reset to settings incase I want to type it in

* Tweak error page

* Cleanup healthcheck icons in service listing

* Centralize errors and make getting back easier

* Nice numbers

* Compact buttons

* Some incidental css cleanups

* Use 'Key / Value' for root

* Tweak tomography layout

* Fix single healthcheck unhealthy resource

* Get loading screen ready

* Fix healthy healthcheck tick

* Everything in header starts white

* First draft loader

* Refactor the entire backend to use proper unique keys, plus..

1. Make unique keys form dc + slug (uid)
2. Fun with errors...

* Tweak header colors

* Add noopener noreferrer to external links

* Add supers to setupController

* Implement cloning, using ember-data...

* Move the more expensive down the switch order

* First draft empty record cleanup..

* Add the cusomt store test

* Temporarily use the htmlSafe prototype to remove the console warning

* Encode hashes in urls

* Go back to using title for errors for now

* Start removing unused bulma

* Lint

* WIP: Start looking at failing tests

* Remove single redirect test

* Finish off error message styling

* Add full ember-data cache invalidation to avoid stale data...

* Add uncolorable warning icons

* More info icon

* Rearrange single service, plus tag printing

* Logo

* No quotes

* Add a simple startup logo

* Tweak healthcheck statuses

* Fix border-color for healthchecks

* Tweak node tabs

* Catch 401 ACL errors and rethrow with the provided error message

* Remove old acl unauth and error routes

* Missed a super

* Make 'All' refer to number of checks, not services

* Remove ember-resizer, add autoprefixer

* Don't show tomography if its not worth it, viewify it more also

* Little model cleanup

* Chevrons

* Find a way to reliably set the class of html from the view

* Consistent html

* Make sure session id's are visible as long as possible

* Fix single service check count

* Add filters and searchs to the query string

* Don't remember the selected tab

* Change text

* Eror tweaking

* Use chevrons on all breadcrumbs even in kv's

* Clean up a file

* Tweak some messaging

* Makesure the footer overlays whats in the page

* Tweak KV errors

* Move json toggle over to the right

* feedback-dialog along with copy buttons

* Better confirmation dialogs

* Add git sha comment

* Same title as old UI

* Allow defaults

* Make sure value is a string

* WIP: Scrolling dropdowns/confirmations

* Add to kv's

* Remove set

* First pass trace

* Better table rows

* Pull over the hashi code editor styles

* Editor tweaks

* Responsive tabs

* Add number formatting to tomography

* Review whats left todo

* Lint

* Add a coordinate ember data triplet

* Bump in a v2.0.0

* Update old tests

* Get coverage working again

* Make sure query keys are also encoded

* Don't test console.error

* Unit test some more utils

* Tweak the size of the tabular collections

* Clean up gitignore

* Fix copy button rollovers

* Get healthcheck 'icon icons' onto the text baseline

* Tweak healthcheck padding and alignment

* Make sure commas kick in in rtt, probably never get to that

* Improve vertical menu

* Tweak dropdown active state to not have a bg

* Tweak paddings

* Search entire string not just 'startsWith'

* Button states

* Most buttons have 1px border

* More button tweaks

* You can only view kv folders

* CSS cleanup reduction

* Form input states and little cleanup

* More CSS reduction

* Sort checks by importance

* Fix click outside on datacenter picker

* Make sure table th's also auto calculate properly

* Make sure `json` isn't remembered in KV editing

* Fix recursive deletion in KV's

* Centralize size

* Catch updateRecord

* Don't double envode

* model > item consistency

* Action loading and ACL tweaks

* Add settings dependencies to acl tests

* Better loading

* utf-8 base64 encode/decode

* Don't hang off a prototype for htmlSafe

* Missing base64 files...

* Get atob/btoa polyfill right

* Shadowy rollovers

* Disabled button styling for primaries

* autofocuses only onload for now

* Fix footer centering

* Beginning of 'notices'

* Remove the isLocked disabling as we are letting you do what the API does

* Don't forget the documentation link for sessions

* Updates are more likely

* Use exported constant

* Dont export redirectFS and a few other PR updates

* Remove the old bootstrap config which was used for the old UI skin

* Use curlies for multiple properties
This commit is contained in:
John Cowen 2018-05-10 19:52:53 +01:00 committed by GitHub
parent 4fffd3b658
commit ca15998b51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
371 changed files with 19758 additions and 324 deletions

View File

@ -121,8 +121,7 @@ ui:
# also run as part of the release build script when it verifies that there are no
# changes to the UI assets that aren't checked in.
static-assets:
@go-bindata-assetfs -pkg agent -prefix pkg ./pkg/web_ui/...
@mv bindata.go agent/bindata_assetfs.go
@go-bindata-assetfs -pkg agent -prefix pkg -o agent/bindata_assetfs.go ./pkg/web_ui/...
$(MAKE) format
tools:

File diff suppressed because one or more lines are too long

View File

@ -7,6 +7,7 @@ import (
"net/http"
"net/http/pprof"
"net/url"
"os"
"regexp"
"strconv"
"strings"
@ -41,6 +42,18 @@ type HTTPServer struct {
proto string
}
type redirectFS struct {
fs http.FileSystem
}
func (fs *redirectFS) Open(name string) (http.File, error) {
file, err := fs.fs.Open(name)
if err != nil {
file, err = fs.fs.Open("/index.html")
}
return file, err
}
// endpoint is a Consul-specific HTTP handler that takes the usual arguments in
// but returns a response object and error, both of which are handled in a
// common manner by Consul's HTTP server.
@ -135,11 +148,32 @@ func (s *HTTPServer) handler(enableDebug bool) http.Handler {
handleFuncMetrics("/debug/pprof/symbol", pprof.Symbol)
}
// Use the custom UI dir if provided.
if s.agent.config.UIDir != "" {
mux.Handle("/ui/", http.StripPrefix("/ui/", http.FileServer(http.Dir(s.agent.config.UIDir))))
} else if s.agent.config.EnableUI {
mux.Handle("/ui/", http.StripPrefix("/ui/", http.FileServer(assetFS())))
if s.IsUIEnabled() {
new_ui, err := strconv.ParseBool(os.Getenv("CONSUL_UI_BETA"))
if err != nil {
new_ui = false
}
var uifs http.FileSystem;
// Use the custom UI dir if provided.
if s.agent.config.UIDir != "" {
uifs = http.Dir(s.agent.config.UIDir)
} else {
fs := assetFS()
if new_ui {
fs.Prefix += "/v2/"
} else {
fs.Prefix += "/v1/"
}
uifs = fs
}
if new_ui {
uifs = &redirectFS{fs:uifs}
}
mux.Handle("/ui/", http.StripPrefix("/ui/", http.FileServer(uifs)))
}
// Wrap the whole mux with a handler that bans URLs with non-printable

View File

@ -11,8 +11,12 @@ RUN apt-get update -y && \
ruby \
ruby-dev \
zip \
zlib1g-dev && \
gem install bundler
zlib1g-dev \
nodejs \
npm && \
gem install bundler && \
npm install --global yarn && \
npm install --global ember-cli
RUN mkdir /goroot && \
mkdir /gopath && \

View File

@ -18,6 +18,11 @@ bundle
make dist
popd
pushd ui-v2
yarn install
make dist
popd
# Fixup the timestamps to match what's checked in. This will allow us to cleanly
# verify that the checked-in content is up to date without spurious diffs of the
# file mod times.

View File

@ -13,11 +13,18 @@ cd $DIR
make tools
# Build the web assets.
echo "Building the V1 UI"
pushd ui
bundle
make dist
popd
echo "Building the V2 UI"
pushd ui-v2
yarn install
make dist
popd
# Make the static assets using the container version of the builder
make static-assets

8
ui-v2/.dev.eslintrc.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
extends: ['./.eslintrc.js'],
rules: {
'no-console': 'warn',
'no-unused-vars': ['error', { args: 'none' }],
'ember/routes-segments-snake-case': 'warn',
},
};

22
ui-v2/.editorconfig Normal file
View File

@ -0,0 +1,22 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
[*.{js,scss}]
insert_final_newline = true
indent_style = space
indent_size = 2
[*.hbs]
insert_final_newline = false
[*.{diff,md}]
trim_trailing_whitespace = false

9
ui-v2/.ember-cli Normal file
View File

@ -0,0 +1,9 @@
{
/**
Ember CLI sends analytics information by default. The data is completely
anonymous, but there are times when you might want to disable this behavior.
Setting `disableAnalytics` to true will prevent any data from being sent.
*/
"disableAnalytics": false
}

41
ui-v2/.eslintrc.js Normal file
View File

@ -0,0 +1,41 @@
module.exports = {
root: true,
parserOptions: {
ecmaVersion: 2017,
sourceType: 'module',
ecmaFeatures: {
experimentalObjectRestSpread: true
}
},
plugins: ['ember'],
extends: ['eslint:recommended', 'plugin:ember/recommended'],
env: {
browser: true,
},
rules: {
'no-unused-vars': ['error', { args: 'none' }]
},
overrides: [
// node files
{
files: ['testem.js', 'ember-cli-build.js', 'config/**/*.js'],
parserOptions: {
sourceType: 'script',
ecmaVersion: 2015,
},
env: {
browser: false,
node: true,
},
},
// test files
{
files: ['tests/**/*.js'],
excludedFiles: ['tests/dummy/**/*.js'],
env: {
embertest: true,
},
},
],
};

10
ui-v2/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
/dist
/tmp
/node_modules
/coverage/*
/npm-debug.log*
/yarn-error.log
/testem.log

1
ui-v2/.nvmrc Normal file
View File

@ -0,0 +1 @@
8

3
ui-v2/.prettierrc Normal file
View File

@ -0,0 +1,3 @@
printWidth: 100
singleQuote: true
trailingComma: es5

3
ui-v2/.watchmanconfig Normal file
View File

@ -0,0 +1,3 @@
{
"ignore_dirs": ["tmp", "dist"]
}

15
ui-v2/GNUmakefile Normal file
View File

@ -0,0 +1,15 @@
ROOT:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
server:
yarn run start
dist:
yarn run build
mv dist ../pkg/web_ui/v2
lint:
yarn run lint:js
format:
yarn run format:js
.PHONY: server dist lint format

49
ui-v2/README.md Normal file
View File

@ -0,0 +1,49 @@
# consul-ui
## Prerequisites
You will need the following things properly installed on your computer.
* [Git](https://git-scm.com/)
* [Node.js](https://nodejs.org/) (with npm)
* [yarn](https://yarnpkg.com)
* [Ember CLI](https://ember-cli.com/)
* [Google Chrome](https://google.com/chrome/)
## Installation
* `git clone <repository-url>` this repository
* `cd ui`
* `yarn install`
## Running / Development
* `yarn run start`
* Visit your app at [http://localhost:4200](http://localhost:4200).
* Visit your tests at [http://localhost:4200/tests](http://localhost:4200/tests).
### Code Generators
Make use of the many generators for code, try `ember help generate` for more details
### Running Tests
* `ember test`
* `ember test --server`
### Building
* `ember build` (development)
* `ember build --environment production` (production)
### Deploying
## Further Reading / Useful Links
* [ember.js](https://emberjs.com/)
* [ember-cli](https://ember-cli.com/)
* Development Browser Extensions
* [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi)
* [ember inspector for firefox](https://addons.mozilla.org/en-US/firefox/addon/ember-inspector/)

162
ui-v2/app/adapters/acl.js Normal file
View File

@ -0,0 +1,162 @@
import Adapter, {
REQUEST_CREATE,
REQUEST_UPDATE,
REQUEST_DELETE,
DATACENTER_KEY as API_DATACENTER_KEY,
} from './application';
import EmberError from '@ember/error';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/acl';
import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc';
import { PUT as HTTP_PUT } from 'consul-ui/utils/http/method';
import { OK as HTTP_OK, UNAUTHORIZED as HTTP_UNAUTHORIZED } from 'consul-ui/utils/http/status';
import makeAttrable from 'consul-ui/utils/makeAttrable';
const REQUEST_CLONE = 'cloneRecord';
export default Adapter.extend({
urlForQuery: function(query, modelName) {
// https://www.consul.io/api/acl.html#list-acls
return this.appendURL('acl/list', [], this.cleanQuery(query));
},
urlForQueryRecord: function(query, modelName) {
// https://www.consul.io/api/acl.html#read-acl-token
return this.appendURL('acl/info', [query.id], this.cleanQuery(query));
},
urlForCreateRecord: function(modelName, snapshot) {
// https://www.consul.io/api/acl.html#create-acl-token
return this.appendURL('acl/create', [], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForUpdateRecord: function(id, modelName, snapshot) {
// the id is in the payload, don't add it in here
// https://www.consul.io/api/acl.html#update-acl-token
return this.appendURL('acl/update', [], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForDeleteRecord: function(id, modelName, snapshot) {
// https://www.consul.io/api/acl.html#delete-acl-token
return this.appendURL('acl/destroy', [snapshot.attr(SLUG_KEY)], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForCloneRecord: function(modelName, snapshot) {
// https://www.consul.io/api/acl.html#clone-acl-token
return this.appendURL('acl/clone', [snapshot.attr(SLUG_KEY)], {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForRequest: function({ type, snapshot, requestType }) {
switch (requestType) {
case 'cloneRecord':
return this.urlForCloneRecord(type.modelName, snapshot);
}
return this._super(...arguments);
},
clone: function(store, modelClass, id, snapshot) {
const params = {
store: store,
type: modelClass,
id: id,
snapshot: snapshot,
requestType: 'cloneRecord',
};
// _requestFor is private... but these methods aren't, until they disappear..
const request = {
method: this.methodForRequest(params),
url: this.urlForRequest(params),
headers: this.headersForRequest(params),
data: this.dataForRequest(params),
};
// TODO: private..
return this._makeRequest(request);
},
dataForRequest: function(params) {
const data = this._super(...arguments);
switch (params.requestType) {
case REQUEST_UPDATE:
case REQUEST_CREATE:
return data.acl;
}
return data;
},
methodForRequest: function(params) {
switch (params.requestType) {
case REQUEST_DELETE:
case REQUEST_CREATE:
case REQUEST_CLONE:
return HTTP_PUT;
}
return this._super(...arguments);
},
isCreateRecord: function(url) {
return (
url.pathname ===
this.parseURL(this.urlForCreateRecord('acl', makeAttrable({ [DATACENTER_KEY]: '' }))).pathname
);
},
isCloneRecord: function(url) {
return (
url.pathname ===
this.parseURL(
this.urlForCloneRecord(
'acl',
makeAttrable({ [SLUG_KEY]: this.slugFromURL(url), [DATACENTER_KEY]: '' })
)
).pathname
);
},
isUpdateRecord: function(url) {
return (
url.pathname ===
this.parseURL(this.urlForUpdateRecord(null, 'acl', makeAttrable({ [DATACENTER_KEY]: '' })))
.pathname
);
},
handleResponse: function(status, headers, payload, requestData) {
let response = payload;
if (status === HTTP_OK) {
const url = this.parseURL(requestData.url);
switch (true) {
case response === true:
response = {
[PRIMARY_KEY]: this.uidForURL(url),
};
break;
case this.isQueryRecord(url):
response = {
...response[0],
...{
[PRIMARY_KEY]: this.uidForURL(url),
},
};
break;
case this.isUpdateRecord(url):
case this.isCreateRecord(url):
case this.isCloneRecord(url):
response = {
...response,
...{
[PRIMARY_KEY]: this.uidForURL(url, response[SLUG_KEY]),
},
};
break;
default:
response = response.map((item, i, arr) => {
return {
...item,
...{
[PRIMARY_KEY]: this.uidForURL(url, item[SLUG_KEY]),
},
};
});
}
} else if (status === HTTP_UNAUTHORIZED) {
const e = new EmberError();
e.code = status;
e.message = payload;
throw e;
}
return this._super(status, headers, response, requestData);
},
});

View File

@ -0,0 +1,82 @@
import Adapter from 'ember-data/adapters/rest';
import { inject as service } from '@ember/service';
import URL from 'url';
import createURL from 'consul-ui/utils/createURL';
export const REQUEST_CREATE = 'createRecord';
export const REQUEST_READ = 'queryRecord';
export const REQUEST_UPDATE = 'updateRecord';
export const REQUEST_DELETE = 'deleteRecord';
// export const REQUEST_READ_MULTIPLE = 'query';
export const DATACENTER_KEY = 'dc';
export default Adapter.extend({
namespace: 'v1',
repo: service('settings'),
headersForRequest: function(params) {
return {
...this.get('repo').findHeaders(),
...this._super(...arguments),
};
},
cleanQuery: function(_query) {
delete _query.id;
const query = { ..._query };
delete _query[DATACENTER_KEY];
return query;
},
isQueryRecord: function(url) {
// this is ONLY if ALL api's using it
// follow the 'last part of the url is the id' rule
const pathname = url.pathname
.split('/') // unslashify
// remove the last
.slice(0, -1)
// add and empty to ensure a trailing slash
.concat([''])
// slashify
.join('/');
// compare with empty id against empty id
return pathname === this.parseURL(this.urlForQueryRecord({ id: '' })).pathname;
},
getHost: function() {
return this.host || `${location.protocol}//${location.host}`;
},
slugFromURL: function(url) {
// follow the 'last part of the url is the id' rule
return decodeURIComponent(url.pathname.split('/').pop());
},
parseURL: function(str) {
return new URL(str, this.getHost());
},
uidForURL: function(url, _slug = '') {
const dc = url.searchParams.get(DATACENTER_KEY) || '';
const slug = _slug === '' ? this.slugFromURL(url) : _slug;
if (dc.length < 1) {
throw new Error('Unable to create unique id, missing datacenter');
}
if (slug.length < 1) {
throw new Error('Unable to create unique id, missing slug');
}
// TODO: we could use a URL here? They are unique AND useful
// but probably slower to create?
return JSON.stringify([dc, slug]);
},
// appendURL in turn calls createURL
// createURL ensures that all `parts` are URL encoded
// and all `query` values are URL encoded
// `this.buildURL()` with no arguments will give us `${host}/${namespace}`
// `path` is the user configurable 'urlsafe' string to append on `buildURL`
// `parts` is an array of possibly non 'urlsafe parts' to be encoded and
// appended onto the url
// `query` will populate the query string. Again the values of which will be
// url encoded
appendURL: function(path, parts = [], query = {}) {
return createURL([this.buildURL(), path], parts, query);
},
});

View File

@ -0,0 +1,27 @@
import ApplicationAdapter from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/coordinate';
import { OK as HTTP_OK } from 'consul-ui/utils/http/status';
export default ApplicationAdapter.extend({
urlForQuery: function(query, modelName) {
// https://www.consul.io/api/coordinate.html#read-lan-coordinates-for-all-nodes
return this.appendURL('coordinate/nodes', [], this.cleanQuery(query));
},
handleResponse: function(status, headers, payload, requestData) {
let response = payload;
if (status === HTTP_OK) {
const url = this.parseURL(requestData.url);
response = response.map((item, i, arr) => {
return {
...item,
...{
[PRIMARY_KEY]: this.uidForURL(url, item[SLUG_KEY]),
},
};
});
}
return this._super(status, headers, response, requestData);
},
});

7
ui-v2/app/adapters/dc.js Normal file
View File

@ -0,0 +1,7 @@
import Adapter from './application';
export default Adapter.extend({
urlForFindAll: function() {
return this.appendURL('catalog/datacenters');
},
});

141
ui-v2/app/adapters/kv.js Normal file
View File

@ -0,0 +1,141 @@
import Adapter, {
REQUEST_CREATE,
REQUEST_UPDATE,
REQUEST_DELETE,
DATACENTER_KEY as API_DATACENTER_KEY,
} from './application';
import isFolder from 'consul-ui/utils/isFolder';
import injectableRequestToJQueryAjaxHash from 'consul-ui/utils/injectableRequestToJQueryAjaxHash';
import { typeOf } from '@ember/utils';
import { get } from '@ember/object';
import { inject as service } from '@ember/service';
import keyToArray from 'consul-ui/utils/keyToArray';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/kv';
import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc';
import { PUT as HTTP_PUT, DELETE as HTTP_DELETE } from 'consul-ui/utils/http/method';
import { OK as HTTP_OK } from 'consul-ui/utils/http/status';
const API_KEYS_KEY = 'keys';
const stringify = function(obj) {
if (typeOf(obj) === 'string') {
return obj;
}
return JSON.stringify(obj);
};
export default Adapter.extend({
// There is no code path that can avoid the payload of a PUT request from
// going via JSON.stringify.
// Therefore a string payload of 'foobar' will always be encoded to '"foobar"'
//
// This means we have no other choice but rewriting the entire codepath or
// overwriting the private `_requestToJQueryAjaxHash` method
//
// The `injectableRequestToJQueryAjaxHash` function makes the JSON object
// injectable, meaning we can copy letter for letter the sourcecode of
// `_requestToJQueryAjaxHash`, which means we can compare it with the original
// private method within a test (`tests/unit/utils/injectableRequestToJQueryAjaxHash.js`).
// This means, if `_requestToJQueryAjaxHash` changes between Ember versions
// we will know about it
_requestToJQueryAjaxHash: injectableRequestToJQueryAjaxHash({
stringify: stringify,
}),
decoder: service('atob'),
urlForQuery: function(query, modelName) {
// append keys here otherwise query.keys will add an '='
return this.appendURL('kv', keyToArray(query.id), {
...{
[API_KEYS_KEY]: null,
},
...this.cleanQuery(query),
});
},
urlForQueryRecord: function(query, modelName) {
return this.appendURL('kv', keyToArray(query.id), this.cleanQuery(query));
},
urlForCreateRecord: function(modelName, snapshot) {
return this.appendURL('kv', keyToArray(snapshot.attr(SLUG_KEY)), {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForUpdateRecord: function(id, modelName, snapshot) {
return this.appendURL('kv', keyToArray(snapshot.attr(SLUG_KEY)), {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
});
},
urlForDeleteRecord: function(id, modelName, snapshot) {
const query = {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
};
if (isFolder(snapshot.attr(SLUG_KEY))) {
query.recurse = null;
}
return this.appendURL('kv', keyToArray(snapshot.attr(SLUG_KEY)), query);
},
slugFromURL: function(url) {
// keys don't follow the 'last part of the url' rule as they contain slashes
return decodeURIComponent(
url.pathname
.split('/')
.splice(3)
.join('/')
);
},
isQueryRecord: function(url) {
return !url.searchParams.has(API_KEYS_KEY);
},
handleResponse: function(status, headers, payload, requestData) {
let response = payload;
if (status === HTTP_OK) {
const url = this.parseURL(requestData.url);
switch (true) {
case response === true:
response = {
[PRIMARY_KEY]: this.uidForURL(url),
};
break;
case this.isQueryRecord(url):
response = {
...response[0],
...{
[PRIMARY_KEY]: this.uidForURL(url),
},
};
break;
default:
// isQuery
response = response.map((item, i, arr) => {
return {
[PRIMARY_KEY]: this.uidForURL(url, item),
[SLUG_KEY]: item,
};
});
}
}
return this._super(status, headers, response, requestData);
},
dataForRequest: function(params) {
const data = this._super(...arguments);
let value = '';
switch (params.requestType) {
case REQUEST_UPDATE:
case REQUEST_CREATE:
value = data.kv.Value;
if (typeof value === 'string') {
return get(this, 'decoder').execute(value);
}
return null;
}
},
methodForRequest: function(params) {
switch (params.requestType) {
case REQUEST_DELETE:
return HTTP_DELETE;
case REQUEST_CREATE:
return HTTP_PUT;
}
return this._super(...arguments);
},
});

View File

@ -0,0 +1,37 @@
import Adapter from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/node';
import { OK as HTTP_OK } from 'consul-ui/utils/http/status';
export default Adapter.extend({
urlForQuery: function(query, modelName) {
return this.appendURL('internal/ui/nodes', [], this.cleanQuery(query));
},
urlForQueryRecord: function(query, modelName) {
return this.appendURL('internal/ui/node', [query.id], this.cleanQuery(query));
},
handleResponse: function(status, headers, payload, requestData) {
let response = payload;
if (status === HTTP_OK) {
const url = this.parseURL(requestData.url);
switch (true) {
case this.isQueryRecord(url):
response = {
...response,
...{
[PRIMARY_KEY]: this.uidForURL(url),
},
};
break;
default:
response = response.map((item, i, arr) => {
return {
...item,
...{
[PRIMARY_KEY]: this.uidForURL(url, item[SLUG_KEY]),
},
};
});
}
}
return this._super(status, headers, response, requestData);
},
});

View File

@ -0,0 +1,35 @@
import Adapter from './application';
import { PRIMARY_KEY } from 'consul-ui/models/service';
import { OK as HTTP_OK } from 'consul-ui/utils/http/status';
export default Adapter.extend({
urlForQuery: function(query, modelName) {
return this.appendURL('internal/ui/services', [], this.cleanQuery(query));
},
urlForQueryRecord: function(query, modelName) {
return this.appendURL('health/service', [query.id], this.cleanQuery(query));
},
handleResponse: function(status, headers, payload, requestData) {
let response = payload;
if (status === HTTP_OK) {
const url = this.parseURL(requestData.url);
switch (true) {
case this.isQueryRecord(url):
response = {
[PRIMARY_KEY]: this.uidForURL(url),
Nodes: response,
};
break;
default:
response = response.map((item, i, arr) => {
return {
...item,
...{
[PRIMARY_KEY]: this.uidForURL(url, item.Name),
},
};
});
}
}
return this._super(status, headers, response, requestData);
},
});

View File

@ -0,0 +1,59 @@
import Adapter, { REQUEST_DELETE, DATACENTER_KEY as API_DATACENTER_KEY } from './application';
import { FOREIGN_KEY as DATACENTER_KEY } from 'consul-ui/models/dc';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/session';
import { PUT as HTTP_PUT } from 'consul-ui/utils/http/method';
import { OK as HTTP_OK } from 'consul-ui/utils/http/status';
export default Adapter.extend({
urlForQuery: function(query, modelName) {
return this.appendURL('session/node', [query.id], this.cleanQuery(query));
},
urlForQueryRecord: function(query, modelName) {
return this.appendURL('session/info', [query.id], this.cleanQuery(query));
},
urlForDeleteRecord: function(id, modelName, snapshot) {
const query = {
[API_DATACENTER_KEY]: snapshot.attr(DATACENTER_KEY),
};
return this.appendURL('session/destroy', [snapshot.attr(SLUG_KEY)], query);
},
methodForRequest: function(params) {
switch (params.requestType) {
case REQUEST_DELETE:
return HTTP_PUT;
}
return this._super(...arguments);
},
handleResponse: function(status, headers, payload, requestData) {
let response = payload;
if (status === HTTP_OK) {
const url = this.parseURL(requestData.url);
switch (true) {
case response === true:
response = {
[PRIMARY_KEY]: this.uidForURL(url),
};
break;
case this.isQueryRecord(url):
response = {
...response[0],
...{
[PRIMARY_KEY]: this.uidForURL(url),
},
};
break;
default:
response = response.map((item, i, arr) => {
return {
...item,
...{
[PRIMARY_KEY]: this.uidForURL(url, item[SLUG_KEY]),
},
};
});
}
}
return this._super(status, headers, response, requestData);
},
});

14
ui-v2/app/app.js Normal file
View File

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

View File

@ -0,0 +1,8 @@
import Component from '@ember/component';
export default Component.extend({
tagName: 'form',
classNames: ['filter-bar'],
'data-test-acl-filter': true,
onchange: function() {},
});

View File

@ -0,0 +1,6 @@
import Component from '@ember/component';
export default Component.extend({
classNames: ['action-group'],
onchange: function() {},
});

View File

@ -0,0 +1,31 @@
import Component from '@ember/component';
import SlotsMixin from 'ember-block-slots';
import { get } from '@ember/object';
const $html = document.documentElement;
const templatize = function(arr = []) {
return arr.map(item => `template-${item}`);
};
export default Component.extend(SlotsMixin, {
loading: false,
classNames: ['app-view'],
didReceiveAttrs: function() {
let cls = get(this, 'class') || '';
if (get(this, 'loading')) {
cls += ' loading';
} else {
$html.classList.remove(...templatize(['loading']));
}
if (cls) {
$html.classList.add(...templatize(cls.split(' ')));
}
},
didInsertElement: function() {
this.didReceiveAttrs();
},
didDestroyElement: function() {
const cls = get(this, 'class') + ' loading';
if (cls) {
$html.classList.remove(...templatize(cls.split(' ')));
}
},
});

View File

@ -0,0 +1,8 @@
import Component from '@ember/component';
export default Component.extend({
tagName: 'form',
classNames: ['filter-bar'],
'data-test-catalog-filter': true,
onchange: function() {},
});

View File

@ -0,0 +1,6 @@
import Component from '@ember/component';
export default Component.extend({
mode: 'application/json',
onkeyup: function() {},
});

View File

@ -0,0 +1,47 @@
/*eslint ember/closure-actions: "warn"*/
import Component from '@ember/component';
import SlotsMixin from 'ember-block-slots';
import { get, set } from '@ember/object';
import { inject as service } from '@ember/service';
const cancel = function() {
set(this, 'confirming', false);
};
const execute = function() {
this.sendAction(...['actionName', ...get(this, 'arguments')]);
};
const confirm = function() {
const [action, ...args] = arguments;
set(this, 'actionName', action);
set(this, 'arguments', args);
if (this._isRegistered('dialog')) {
set(this, 'confirming', true);
} else {
get(this, 'confirm')
.execute(get(this, 'message'))
.then(confirmed => {
if (confirmed) {
this.execute();
}
})
.catch(function() {
return get(this, 'error').execute(...arguments);
});
}
};
export default Component.extend(SlotsMixin, {
classNameBindings: ['confirming'],
confirm: service('confirm'),
error: service('error'),
classNames: ['with-confirmation'],
message: 'Are you sure?',
confirming: false,
permanent: false,
init: function() {
this._super(...arguments);
this.cancel = cancel.bind(this);
this.execute = execute.bind(this);
this.confirm = confirm.bind(this);
},
});

View File

@ -0,0 +1,7 @@
import Component from '@ember/component';
import WithClickOutside from 'consul-ui/mixins/click-outside';
export default Component.extend(WithClickOutside, {
tagName: 'ul',
onchange: function() {},
});

View File

@ -0,0 +1,42 @@
import Component from '@ember/component';
import { get, set } from '@ember/object';
import { inject as service } from '@ember/service';
import SlotsMixin from 'ember-block-slots';
const STATE_READY = 'ready';
const STATE_SUCCESS = 'success';
const STATE_ERROR = 'error';
export default Component.extend(SlotsMixin, {
wait: service('timeout'),
interval: null,
classNames: ['with-feedback'],
state: STATE_READY,
permanent: true,
init: function() {
this._super(...arguments);
this.success = this._success.bind(this);
this.error = this._error.bind(this);
},
_success: function() {
set(this, 'state', STATE_SUCCESS);
get(this, 'wait')
.execute(3000, interval => {
clearInterval(get(this, 'interval'));
set(this, 'interval', interval);
})
.then(() => {
set(this, 'state', STATE_READY);
});
},
_error: function() {
set(this, 'state', STATE_ERROR);
get(this, 'wait')
.execute(3000, interval => {
clearInterval(get(this, 'interval'));
set(this, 'interval', interval);
})
.then(() => {
set(this, 'state', STATE_READY);
});
},
});

View File

@ -0,0 +1,7 @@
import Component from '@ember/component';
export default Component.extend({
tagName: 'fieldset',
classNames: ['freetext-filter'],
onchange: function(){}
});

View File

@ -0,0 +1,26 @@
import Component from '@ember/component';
import { get, set } from '@ember/object';
const $html = document.documentElement;
const $body = document.body;
export default Component.extend({
isDropdownVisible: false,
didInsertElement: function() {
$html.classList.remove('template-with-vertical-menu');
},
actions: {
dropdown: function(e) {
if (get(this, 'dcs.length') > 0) {
set(this, 'isDropdownVisible', !get(this, 'isDropdownVisible'));
}
},
change: function(e) {
if (e.target.checked) {
$html.classList.add('template-with-vertical-menu');
$body.style.height = $html.style.height = window.innerHeight + 'px';
} else {
$html.classList.remove('template-with-vertical-menu');
$body.style.height = $html.style.height = null;
}
},
},
});

View File

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

View File

@ -0,0 +1,25 @@
import Component from '@ember/component';
import { computed, get } from '@ember/object';
import style from 'ember-computed-style';
export default Component.extend({
classNames: ['healthchecked-resource'],
attributeBindings: ['style'],
style: style('gridRowEnd'),
unhealthy: computed.filter(`checks.@each.Status`, function(item) {
const status = get(item, 'Status');
return status === 'critical' || status === 'warning';
}),
healthy: computed.filter(`checks.@each.Status`, function(item) {
const status = get(item, 'Status');
return status === 'passing';
}),
gridRowEnd: computed('UnhealthyChecks', function() {
let spans = 3;
if (get(this, 'healthy.length') > 0) {
spans++;
}
return {
gridRow: `auto / span ${spans + (get(this, 'unhealthy.length') || 0)}`,
};
}),
});

View File

@ -0,0 +1,6 @@
import Component from 'ember-collection/components/ember-collection';
export default Component.extend({
tagName: 'div',
classNames: ['list-collection'],
});

View File

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

View File

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

View File

@ -0,0 +1,11 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
export default Component.extend({
classNames: ['tab-section'],
'data-test-radiobutton': computed('name,id', function() {
return `${this.get('name')}_${this.get('id')}`;
}),
name: 'tab',
onchange: function() {},
});

View File

@ -0,0 +1,130 @@
import Component from 'ember-collection/components/ember-collection';
import needsRevalidate from 'ember-collection/utils/needs-revalidate';
import Grid from 'ember-collection/layouts/grid';
import SlotsMixin from 'ember-block-slots';
import style from 'ember-computed-style';
import { computed, get, set } from '@ember/object';
const $$ = document.querySelectorAll.bind(document);
const createSizeEvent = function(detail) {
return {
detail: { width: window.innerWidth, height: window.innerHeight },
};
};
class ZIndexedGrid extends Grid {
formatItemStyle(index, w, h) {
let style = super.formatItemStyle(...arguments);
style += 'z-index: ' + (10000 - index);
return style;
}
}
// TODO instead of degrading gracefully
// add a while polyfill for closest
const closest = function(sel, el) {
try {
return el.closest(sel);
} catch (e) {
return;
}
};
const change = function(e) {
if (e instanceof MouseEvent) {
return;
}
// TODO: Why am I getting a jQuery event here?!
if (e instanceof Event) {
const value = e.currentTarget.value;
if (value != get(this, 'checked')) {
set(this, 'checked', value);
} else {
set(this, 'checked', null);
}
} else if (e.detail && e.detail.index) {
if (e.detail.confirming) {
this.confirming.push(e.detail.index);
} else {
const pos = this.confirming.indexOf(e.detail.index);
if (pos !== -1) {
this.confirming.splice(pos, 1);
}
}
}
};
export default Component.extend(SlotsMixin, {
tagName: 'table',
attributeBindings: ['style'],
width: 1150,
height: 500,
style: style('getStyle'),
checked: null,
init: function() {
this._super(...arguments);
this.change = change.bind(this);
this.confirming = [];
// TODO: This should auto calculate properly from the CSS
this['cell-layout'] = new ZIndexedGrid(get(this, 'width'), 50);
this.handler = () => {
this.resize(createSizeEvent());
};
},
getStyle: computed('height', function() {
return {
height: get(this, 'height'),
};
}),
willRender: function() {
this._super(...arguments);
this.set('hasActions', this._isRegistered('actions'));
},
didInsertElement: function() {
this._super(...arguments);
window.addEventListener('resize', this.handler);
this.handler();
},
willDestroyElement: function() {
window.removeEventListener('resize', this.handler);
},
resize: function(e) {
const $footer = [...$$('#wrapper > footer')][0];
const $thead = [...$$('main > div')][0];
if ($thead) {
// TODO: This should auto calculate properly from the CSS
this.set('height', Math.max(0, new Number(e.detail.height - ($footer.clientHeight + 218))));
this['cell-layout'] = new ZIndexedGrid($thead.clientWidth, 50);
this.updateItems();
this.updateScrollPosition();
}
},
_needsRevalidate: function() {
if (this.isDestroyed || this.isDestroying) {
return;
}
if (this._isGlimmer2()) {
this.rerender();
} else {
needsRevalidate(this);
}
},
actions: {
click: function(e) {
const name = e.target.nodeName.toLowerCase();
switch (name) {
case 'input':
case 'label':
case 'a':
case 'button':
return;
}
const $a = closest('tr', e.target).querySelector('a');
if ($a) {
const click = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
});
$a.dispatchEvent(click);
}
},
},
});

View File

@ -0,0 +1,57 @@
import Component from '@ember/component';
import { computed, set, get } from '@ember/object';
const size = 336;
const insetSize = size / 2 - 8;
const inset = function(num) {
return insetSize * num;
};
const milliseconds = function(num, max) {
return max > 0 ? parseInt(max * num) / 100 : 0;
};
export default Component.extend({
size: size,
tomography: 0,
max: -999999999,
init: function() {
this._super(...arguments);
this.circle = [inset(1), inset(0.25), inset(0.5), inset(0.75), inset(1)];
this.labels = [inset(-0.25), inset(-0.5), inset(-0.75), inset(-1)];
},
milliseconds: computed('distances', 'max', function() {
const max = get(this, 'max');
return [
milliseconds(25, max),
milliseconds(50, max),
milliseconds(75, max),
milliseconds(100, max),
];
}),
distances: computed('tomography', function() {
const tomography = this.get('tomography');
let distances = tomography.distances || [];
distances.forEach((d, i) => {
if (d.distance > get(this, 'max')) {
set(this, 'max', d.distance);
}
});
if (tomography.n > 360) {
let n = distances.length;
// We have more nodes than we want to show, take a random sampling to keep
// the number around 360.
const sampling = 360 / tomography.n;
distances = distances.filter(function(_, i) {
return i == 0 || i == n - 1 || Math.random() < sampling;
});
}
return distances.map((d, i) => {
return {
rotate: i * 360 / distances.length,
y2: -insetSize * (d.distance / get(this, 'max')),
node: d.node,
distance: d.distance,
segment: d.segment,
};
});
}),
});

View File

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

View File

@ -0,0 +1,2 @@
import Controller from './edit';
export default Controller.extend();

View File

@ -0,0 +1,30 @@
import Controller from '@ember/controller';
import { set } from '@ember/object';
import Changeset from 'ember-changeset';
import validations from 'consul-ui/validations/acl';
import lookupValidator from 'ember-changeset-validations';
export default Controller.extend({
setProperties: function(model) {
this.changeset = new Changeset(model.item, lookupValidator(validations), validations);
this._super({
...model,
...{
item: this.changeset,
},
});
},
actions: {
change: function(e) {
const target = e.target || { name: 'Rules', value: e };
switch (target.name) {
case 'Type':
set(this.changeset, target.name, target.value);
break;
case 'Rules':
set(this, 'item.Rules', target.value);
break;
}
},
},
});

View File

@ -0,0 +1,43 @@
import Controller from '@ember/controller';
import { computed, get } from '@ember/object';
import WithFiltering from 'consul-ui/mixins/with-filtering';
import ucfirst from 'consul-ui/utils/ucfirst';
import numeral from 'numeral';
const countType = function(items, type) {
return type === '' ? get(items, 'length') : items.filterBy('Type', type).length;
};
export default Controller.extend(WithFiltering, {
queryParams: {
type: {
as: 'type',
},
s: {
as: 'filter',
replace: true,
},
},
typeFilters: computed('items', function() {
const items = get(this, 'items');
return ['', 'management', 'client'].map(function(item) {
return {
label: `${item === '' ? 'All' : ucfirst(item)} (${numeral(
countType(items, item)
).format()})`,
value: item,
};
});
}),
filter: function(item, { s = '', type = '' }) {
return (
get(item, 'Name')
.toLowerCase()
.indexOf(s.toLowerCase()) !== -1 &&
(type === '' || get(item, 'Type') === type)
);
},
actions: {
sendClone: function(item) {
this.send('clone', item);
},
},
});

View File

@ -0,0 +1,2 @@
import Controller from './edit';
export default Controller.extend();

View File

@ -0,0 +1,40 @@
import Controller from '@ember/controller';
import { get, set } from '@ember/object';
import { inject as service } from '@ember/service';
import Changeset from 'ember-changeset';
import validations from 'consul-ui/validations/kv';
import lookupValidator from 'ember-changeset-validations';
export default Controller.extend({
json: false,
encoder: service('btoa'),
setProperties: function(model) {
// TODO: Potentially save whether json has been clicked to the model
set(this, 'json', false);
this.changeset = new Changeset(model.item, lookupValidator(validations), validations);
this._super({
...model,
...{
item: this.changeset,
},
});
},
actions: {
change: function(e) {
const target = e.target || { name: 'value', value: e };
var parent;
switch (target.name) {
case 'additional':
parent = get(this, 'parent.Key');
set(this.changeset, 'Key', `${parent !== '/' ? parent : ''}${target.value}`);
break;
case 'json':
set(this, 'json', !get(this, 'json'));
break;
case 'value':
set(this, 'item.Value', get(this, 'encoder').execute(target.value));
break;
}
},
},
});

View File

@ -0,0 +1,2 @@
import Controller from './create';
export default Controller.extend();

View File

@ -0,0 +1,27 @@
import Controller from '@ember/controller';
import { computed } from '@ember/object';
import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering';
import { get } from '@ember/object';
export default Controller.extend(WithHealthFiltering, {
init: function() {
this._super(...arguments);
this.columns = [25, 25, 25, 25];
},
unhealthy: computed('filtered', function() {
return get(this, 'filtered').filter(function(item) {
return get(item, 'isUnhealthy');
});
}),
healthy: computed('filtered', function() {
return get(this, 'filtered').filter(function(item) {
return get(item, 'isHealthy');
});
}),
filter: function(item, { s = '', status = '' }) {
return (
get(item, 'Node')
.toLowerCase()
.indexOf(s.toLowerCase()) !== -1 && item.hasStatus(status)
);
},
});

View File

@ -0,0 +1,53 @@
import Controller from '@ember/controller';
import { get, set } from '@ember/object';
import WithFiltering from 'consul-ui/mixins/with-filtering';
export default Controller.extend(WithFiltering, {
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
setProperties: function() {
this._super(...arguments);
set(this, 'selectedTab', 'health-checks');
},
filter: function(item, { s = '' }) {
return (
get(item, 'Service')
.toLowerCase()
.indexOf(s.toLowerCase()) !== -1
);
},
actions: {
sortChecksByImportance: function(a, b) {
const statusA = get(a, 'Status');
const statusB = get(b, 'Status');
switch (statusA) {
case 'passing':
// a = passing
// unless b is also passing then a is less important
return statusB === 'passing' ? 0 : 1;
case 'critical':
// a = critical
// unless b is also critical then a is more important
return statusB === 'critical' ? 0 : -1;
case 'warning':
// a = warning
switch (statusB) {
// b is passing so a is more important
case 'passing':
return -1;
// b is critical so a is less important
case 'critical':
return 1;
// a and b are both warning, therefore equal
default:
return 0;
}
}
return 0;
},
},
});

View File

@ -0,0 +1,65 @@
import Controller from '@ember/controller';
import { get, computed } from '@ember/object';
import { htmlSafe } from '@ember/string';
import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering';
const max = function(arr, prop) {
return arr.reduce(function(prev, item) {
return Math.max(prev, get(item, prop));
}, 0);
};
const chunk = function(str, size) {
const num = Math.ceil(str.length / size);
const chunks = new Array(num);
for (let i = 0, o = 0; i < num; ++i, o += size) {
chunks[i] = str.substr(o, size);
}
return chunks;
};
const width = function(num) {
const str = num.toString();
const len = str.length;
const commas = chunk(str, 3).length - 1;
return commas * 4 + len * 10;
};
const widthDeclaration = function(num) {
return htmlSafe(`width: ${num}px`);
};
export default Controller.extend(WithHealthFiltering, {
filter: function(item, { s = '', status = '' }) {
return (
get(item, 'Name')
.toLowerCase()
.indexOf(s.toLowerCase()) !== -1 && item.hasStatus(status)
);
},
totalWidth: computed('{maxPassing,maxWarning,maxCritical}', function() {
const PADDING = 32 * 3 + 13;
return ['maxPassing', 'maxWarning', 'maxCritical'].reduce((prev, item) => {
return prev + width(get(this, item));
}, PADDING);
}),
thWidth: computed('totalWidth', function() {
return widthDeclaration(get(this, 'totalWidth'));
}),
remainingWidth: computed('totalWidth', function() {
return htmlSafe(`width: calc(50% - ${Math.round(get(this, 'totalWidth') / 2)}px)`);
}),
maxPassing: computed('items', function() {
return max(get(this, 'items'), 'ChecksPassing');
}),
maxWarning: computed('items', function() {
return max(get(this, 'items'), 'ChecksWarning');
}),
maxCritical: computed('items', function() {
return max(get(this, 'items'), 'ChecksCritical');
}),
passingWidth: computed('maxPassing', function() {
return widthDeclaration(width(get(this, 'maxPassing')));
}),
warningWidth: computed('maxWarning', function() {
return widthDeclaration(width(get(this, 'maxWarning')));
}),
criticalWidth: computed('maxCritical', function() {
return widthDeclaration(width(get(this, 'maxCritical')));
}),
});

View File

@ -0,0 +1,29 @@
import Controller from '@ember/controller';
import { get } from '@ember/object';
import { computed } from '@ember/object';
import sumOfUnhealthy from 'consul-ui/utils/sumOfUnhealthy';
import hasStatus from 'consul-ui/utils/hasStatus';
import WithHealthFiltering from 'consul-ui/mixins/with-health-filtering';
export default Controller.extend(WithHealthFiltering, {
init: function() {
this._super(...arguments);
this.columns = [25, 25, 25, 25];
},
unhealthy: computed('filtered', function() {
return get(this, 'filtered').filter(function(item) {
return sumOfUnhealthy(item.Checks) > 0;
});
}),
healthy: computed('filtered', function() {
return get(this, 'filtered').filter(function(item) {
return sumOfUnhealthy(item.Checks) === 0;
});
}),
filter: function(item, { s = '', status = '' }) {
return (
get(item, 'Node.Node')
.toLowerCase()
.indexOf(s.toLowerCase()) !== -1 && hasStatus(get(item, 'Checks'), status)
);
},
});

View File

@ -0,0 +1,5 @@
import { helper } from '@ember/component/helper';
import atob from 'consul-ui/utils/atob';
export default helper(function([str = '']) {
return atob(str);
});

7
ui-v2/app/helpers/env.js Normal file
View File

@ -0,0 +1,7 @@
import { helper } from '@ember/component/helper';
import $ from 'consul-ui/config/environment';
export function env([name, def = ''], hash) {
return $[name] != null ? $[name] : def;
}
export default helper(env);

View File

@ -0,0 +1,13 @@
import Helper from '@ember/component/helper';
import { inject as service } from '@ember/service';
import { observer } from '@ember/object';
export default Helper.extend({
router: service('router'),
compute(params) {
return this.get('router').isActive(...params);
},
onURLChange: observer('router.currentURL', function() {
this.recompute();
}),
});

10
ui-v2/app/helpers/last.js Normal file
View File

@ -0,0 +1,10 @@
import { helper } from '@ember/component/helper';
export function last([obj = ''], hash) {
switch (true) {
case typeof obj === 'string':
return obj.substr(-1);
}
}
export default helper(last);

View File

@ -0,0 +1,7 @@
import { helper } from '@ember/component/helper';
export function leftTrim([str = '', search = ''], hash) {
return str.indexOf(search) === 0 ? str.substr(search.length) : str;
}
export default helper(leftTrim);

View File

@ -0,0 +1,8 @@
import { helper } from '@ember/component/helper';
export function rightTrim([str = '', search = ''], hash) {
const pos = str.length - search.length;
return str.indexOf(search) === pos ? str.substr(0, pos) : str;
}
export default helper(rightTrim);

View File

@ -0,0 +1,9 @@
import { helper } from '@ember/component/helper';
// TODO: Currently I'm only using this for hardcoded values
// so ' ' to '-' replacement is sufficient for the moment
export function slugify([str = ''], hash) {
return str.replace(/ /g, '-').toLowerCase();
}
export default helper(slugify);

View File

@ -0,0 +1,7 @@
import { helper } from '@ember/component/helper';
export function split([array = [], separator = ','], hash) {
return array.split(separator);
}
export default helper(split);

33
ui-v2/app/index.html Normal file
View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html class="ember-loading">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Consul by HashiCorp</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{content-for "head"}}
<link rel="icon" type="image/png" href="{{rootURL}}assets/favicon-32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="{{rootURL}}assets/favicon-16x16.png" sizes="16x16">
<link integrity="" rel="stylesheet" href="{{rootURL}}assets/vendor.css">
<link integrity="" rel="stylesheet" href="{{rootURL}}assets/consul-ui.css">
{{content-for "head-footer"}}
</head>
<body>
<noscript>
<div style="margin: 0 auto;">
<h2>JavaScript Required</h2>
<p>Please enable JavaScript in your web browser to use Consul UI.</p>
</div>
</noscript>
{{content-for "body"}}
<svg width="168" height="53" xmlns="http://www.w3.org/2000/svg"><g fill="#919FA8" fill-rule="evenodd"><path d="M26.078 32.12a5.586 5.586 0 1 1 5.577-5.599 5.577 5.577 0 0 1-5.577 5.6M37.009 29.328a2.56 2.56 0 1 1 2.56-2.56 2.551 2.551 0 0 1-2.56 2.56M46.916 31.669a2.56 2.56 0 1 1 .051-.21c-.028.066-.028.13-.051.21M44.588 25.068a2.565 2.565 0 0 1-2.672-.992 2.558 2.558 0 0 1-.102-2.845 2.564 2.564 0 0 1 4.676.764c.072.328.081.667.027 1a2.463 2.463 0 0 1-1.925 2.073M53.932 31.402a2.547 2.547 0 0 1-2.95 2.076 2.559 2.559 0 0 1-2.064-2.965 2.547 2.547 0 0 1 2.948-2.077 2.57 2.57 0 0 1 2.128 2.716.664.664 0 0 0-.05.228M51.857 25.103a2.56 2.56 0 1 1 2.108-2.945c.034.218.043.439.027.658a2.547 2.547 0 0 1-2.135 2.287M49.954 40.113a2.56 2.56 0 1 1 .314-1.037c-.02.366-.128.721-.314 1.037M48.974 16.893a2.56 2.56 0 1 1 .97-3.487c.264.446.375.965.317 1.479a2.56 2.56 0 0 1-1.287 2.008"/><path d="M26.526 52.603c-14.393 0-26.06-11.567-26.06-25.836C.466 12.498 12.133.931 26.526.931a25.936 25.936 0 0 1 15.836 5.307l-3.167 4.117A20.962 20.962 0 0 0 17.304 8.23C10.194 11.713 5.7 18.9 5.714 26.763c-.014 7.862 4.48 15.05 11.59 18.534a20.962 20.962 0 0 0 21.89-2.127l3.168 4.123a25.981 25.981 0 0 1-15.836 5.31zM61 30.15V17.948c0-4.962 2.845-7.85 9.495-7.85 2.484 0 5.048.326 7.252.895l-.561 4.433c-2.164-.406-4.688-.691-6.53-.691-3.486 0-4.608 1.22-4.608 4.108v10.412c0 2.888 1.122 4.108 4.607 4.108 1.843 0 4.367-.284 6.53-.691l.562 4.433c-2.204.57-4.768.895-7.252.895C63.845 38 61 35.112 61 30.15zm36.808.04c0 4.068-1.802 7.81-8.493 7.81-6.69 0-8.494-3.742-8.494-7.81v-5.002c0-4.067 1.803-7.81 8.494-7.81 6.69 0 8.493 3.743 8.493 7.81v5.003zm-4.887-5.165c0-2.237-1.002-3.416-3.606-3.416s-3.606 1.18-3.606 3.416v5.328c0 2.237 1.002 3.417 3.606 3.417s3.606-1.18 3.606-3.417v-5.328zm25.79 12.568h-4.887V23.764c0-1.057-.44-1.586-1.563-1.586-1.201 0-3.325.732-5.088 1.668v13.747h-4.887V17.785h3.726l.48 1.668c2.444-1.22 5.53-2.074 7.813-2.074 3.245 0 4.407 2.318 4.407 5.857v14.357zm18.26-5.775c0 3.823-1.162 6.182-7.052 6.182-2.083 0-4.927-.488-6.73-1.139l.68-3.782c1.643.488 3.807.854 5.81.854 2.164 0 2.484-.488 2.484-1.993 0-1.22-.24-1.83-3.405-2.603-4.768-1.18-5.329-2.4-5.329-6.223 0-3.986 1.723-5.735 7.292-5.735 1.803 0 4.166.244 5.85.691l-.482 3.945c-1.482-.284-3.846-.569-5.368-.569-2.124 0-2.484.488-2.484 1.708 0 1.587.12 1.709 2.764 2.4 5.449 1.464 5.97 2.196 5.97 6.264zm4.357-14.033h4.887v13.83c0 1.057.441 1.586 1.563 1.586 1.202 0 3.325-.733 5.088-1.668V17.785h4.888v19.808h-3.726l-.481-1.667c-2.444 1.22-5.529 2.074-7.812 2.074-3.246 0-4.407-2.318-4.407-5.857V17.785zM168 37.593h-4.888V9.691L168 9v28.593z"/></g></svg>
<script src="{{rootURL}}assets/vendor.js"></script>
<script src="{{rootURL}}assets/consul-ui.js"></script>
{{content-for "body-footer"}}
</body>
</html>

View File

@ -0,0 +1,92 @@
import Mixin from '@ember/object/mixin';
import { get } from '@ember/object';
import { inject as service } from '@ember/service';
import WithFeedback from 'consul-ui/mixins/with-feedback';
export default Mixin.create(WithFeedback, {
settings: service('settings'),
actions: {
create: function(item) {
get(this, 'feedback').execute(
() => {
return get(this, 'repo')
.persist(item)
.then(item => {
return this.transitionTo('dc.acls');
});
},
`Your ACL token has been added.`,
`There was an error adding your ACL token.`
);
},
update: function(item) {
get(this, 'feedback').execute(
() => {
return get(this, 'repo')
.persist(item)
.then(() => {
return this.transitionTo('dc.acls');
});
},
`Your ACL token was saved.`,
`There was an error saving your ACL token.`
);
},
delete: function(item) {
get(this, 'feedback').execute(
() => {
return (
get(this, 'repo')
// ember-changeset doesn't support `get`
// and `data` returns an object not a model
.remove(item)
.then(() => {
switch (this.routeName) {
case 'dc.acls.index':
return this.refresh();
default:
return this.transitionTo('dc.acls');
}
})
);
},
`Your ACL token was deleted.`,
`There was an error deleting your ACL token.`
);
},
cancel: function(item) {
this.transitionTo('dc.acls');
},
use: function(item) {
get(this, 'feedback').execute(
() => {
return get(this, 'settings')
.persist({ token: get(item, 'ID') })
.then(() => {
this.transitionTo('dc.services');
});
},
`Now using new ACL token`,
`There was an error using that ACL token`
);
},
clone: function(item) {
get(this, 'feedback').execute(
() => {
return get(this, 'repo')
.clone(item)
.then(item => {
switch (this.routeName) {
case 'dc.acls.index':
return this.refresh();
default:
return this.transitionTo('dc.acls');
}
});
},
`Your ACL token was cloned.`,
`There was an error cloning your ACL token.`
);
},
},
});

View File

@ -0,0 +1,33 @@
import Mixin from '@ember/object/mixin';
import { next } from '@ember/runloop';
import { get } from '@ember/object';
const isOutside = function(element, e) {
const isRemoved = !e.target || !document.contains(e.target);
const isInside = element === e.target || element.contains(e.target);
return !isRemoved && !isInside;
};
const handler = function(e) {
const el = get(this, 'element');
if (isOutside(el, e)) {
this.onblur(e);
}
};
export default Mixin.create({
init: function() {
this._super(...arguments);
this.handler = handler.bind(this);
},
onchange: function() {},
onblur: function() {},
didInsertElement: function() {
this._super(...arguments);
next(this, () => {
document.addEventListener('click', this.handler);
});
},
willDestroyElement: function() {
this._super(...arguments);
document.removeEventListener('click', this.handler);
},
});

View File

@ -0,0 +1,79 @@
import Mixin from '@ember/object/mixin';
import { get, set } from '@ember/object';
import WithFeedback from 'consul-ui/mixins/with-feedback';
const transitionToList = function(key, transitionTo) {
if (key === '/') {
return transitionTo('dc.kv.index');
} else {
return transitionTo('dc.kv.folder', key);
}
};
export default Mixin.create(WithFeedback, {
actions: {
create: function(item, parent) {
get(this, 'feedback').execute(
() => {
return get(this, 'repo')
.persist(item)
.then(item => {
return transitionToList(get(parent, 'Key'), this.transitionTo.bind(this));
});
},
`Your key has been added.`,
`There was an error adding your key.`
);
},
update: function(item, parent) {
get(this, 'feedback').execute(
() => {
return get(this, 'repo')
.persist(item)
.then(() => {
return transitionToList(get(parent, 'Key'), this.transitionTo.bind(this));
});
},
`Your key has been saved.`,
`There was an error saving your key.`
);
},
delete: function(item, parent) {
get(this, 'feedback').execute(
() => {
return get(this, 'repo')
.remove(item)
.then(() => {
switch (this.routeName) {
case 'dc.kv.index':
return this.refresh();
default:
return transitionToList(get(parent, 'Key'), this.transitionTo.bind(this));
}
});
},
`Your key was deleted.`,
`There was an error deleting your key.`
);
},
cancel: function(item, parent) {
return transitionToList(get(parent, 'Key'), this.transitionTo.bind(this));
},
invalidateSession: function(item) {
const controller = this.controller;
const repo = get(this, 'sessionRepo');
get(this, 'feedback').execute(
() => {
return repo.remove(item).then(() => {
const item = get(controller, 'item');
set(item, 'Session', null);
delete item.Session;
set(controller, 'session', null);
});
},
`The session was invalidated.`,
`There was an error invalidating the session.`
);
},
},
});

View File

@ -0,0 +1,13 @@
import Mixin from '@ember/object/mixin';
import { inject as service } from '@ember/service';
import { get, set } from '@ember/object';
export default Mixin.create({
feedback: service('feedback'),
init: function() {
this._super(...arguments);
set(this, 'feedback', {
execute: get(this, 'feedback').execute.bind(this),
});
},
});

View File

@ -0,0 +1,47 @@
import Mixin from '@ember/object/mixin';
import { computed, get, set } from '@ember/object';
const toKeyValue = function(el) {
const key = el.name;
let value = '';
switch (el.type) {
case 'radio':
case 'search':
case 'text':
value = el.value;
break;
}
return { [key]: value };
};
export default Mixin.create({
filters: {},
filtered: computed('items', 'filters', function() {
const filters = get(this, 'filters');
return get(this, 'items').filter(item => {
return this.filter(item, filters);
});
}),
setProperties: function() {
this._super(...arguments);
const query = get(this, 'queryParams');
query.forEach((item, i, arr) => {
const filters = get(this, 'filters');
Object.keys(item).forEach(key => {
set(filters, key, get(this, key));
});
set(this, 'filters', filters);
});
},
actions: {
filter: function(e) {
const obj = toKeyValue(e.target);
Object.keys(obj).forEach((key, i, arr) => {
set(this, key, obj[key] != '' ? obj[key] : null);
});
set(this, 'filters', {
...this.get('filters'),
...obj,
});
},
},
});

View File

@ -0,0 +1,50 @@
import Mixin from '@ember/object/mixin';
import WithFiltering from 'consul-ui/mixins/with-filtering';
import { computed, get } from '@ember/object';
import ucfirst from 'consul-ui/utils/ucfirst';
import numeral from 'numeral';
const countStatus = function(items, status) {
if (status === '') {
return get(items, 'length');
}
const key = `Checks${ucfirst(status)}`;
return items.reduce(function(prev, item, i, arr) {
const num = get(item, key);
return (
prev +
(typeof num !== 'undefined'
? num
: get(item, 'Checks').filter(function(item) {
return item.Status === status;
}).length) || 0
);
}, 0);
};
export default Mixin.create(WithFiltering, {
queryParams: {
status: {
as: 'status',
},
s: {
as: 'filter',
},
},
healthFilters: computed('items', function() {
const items = get(this, 'items');
const objs = ['', 'passing', 'warning', 'critical'].map(function(item) {
const count = countStatus(items, item);
return {
count: count,
label: `${item === '' ? 'All' : ucfirst(item)} (${numeral(count).format()})`,
value: item,
};
});
objs[0].label = `All (${numeral(
objs.slice(1).reduce(function(prev, item, i, arr) {
return prev + item.count;
}, 0)
).format()})`;
return objs;
}),
});

16
ui-v2/app/models/acl.js Normal file
View File

@ -0,0 +1,16 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'ID';
export default Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
Name: attr('string'),
Type: attr('string'),
Rules: attr('string'),
CreateIndex: attr('number'),
ModifyIndex: attr('number'),
Datacenter: attr('string'),
});

View File

@ -0,0 +1,12 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'Node';
export default Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
Coord: attr(),
Segment: attr('string'),
});

14
ui-v2/app/models/dc.js Normal file
View File

@ -0,0 +1,14 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { hasMany } from 'ember-data/relationships';
export const PRIMARY_KEY = 'uid';
export const FOREIGN_KEY = 'Datacenter';
export const SLUG_KEY = 'Name';
export default Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
Services: hasMany('service'),
Nodes: hasMany('node'),
});

25
ui-v2/app/models/kv.js Normal file
View File

@ -0,0 +1,25 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { computed, get } from '@ember/object';
import isFolder from 'consul-ui/utils/isFolder';
export const PRIMARY_KEY = 'uid';
// not really a slug as it contains slashes but all intents and purposes
// its my 'slug'
export const SLUG_KEY = 'Key';
export default Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
LockIndex: attr('number'),
Flags: attr('number'),
Value: attr('string'),
CreateIndex: attr('string'),
ModifyIndex: attr('string'),
Session: attr('string'),
Datacenter: attr('string'),
isFolder: computed('Key', function() {
return isFolder(get(this, 'Key') || '');
}),
});

33
ui-v2/app/models/node.js Normal file
View File

@ -0,0 +1,33 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { computed, get } from '@ember/object';
import sumOfUnhealthy from 'consul-ui/utils/sumOfUnhealthy';
import hasStatus from 'consul-ui/utils/hasStatus';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'ID';
export default Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
Address: attr('string'),
Node: attr('string'),
Meta: attr(),
Services: attr(),
Checks: attr(),
CreateIndex: attr('number'),
ModifyIndex: attr('number'),
TaggedAddresses: attr(),
Datacenter: attr('string'),
Segment: attr(),
Coord: attr(),
hasStatus: function(status) {
return hasStatus(get(this, 'Checks'), status);
},
isHealthy: computed('Checks', function() {
return sumOfUnhealthy(get(this, 'Checks')) === 0;
}),
isUnhealthy: computed('Checks', function() {
return sumOfUnhealthy(get(this, 'Checks')) > 0;
}),
});

View File

@ -0,0 +1,59 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { computed, get } from '@ember/object';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'Name';
export default Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
Tags: attr({
defaultValue: function() {
return [];
},
}),
Address: attr('string'),
Port: attr('number'),
EnableTagOverride: attr('boolean'),
CreateIndex: attr('number'),
ModifyIndex: attr('number'),
ChecksPassing: attr(),
ChecksCritical: attr(),
ChecksWarning: attr(),
Nodes: attr(),
Datacenter: attr('string'),
Node: attr(),
Service: attr(),
Checks: attr(),
passing: computed('ChecksPassing', 'Checks', function() {
let num = 0;
// TODO: use typeof
if (get(this, 'ChecksPassing') !== undefined) {
num = get(this, 'ChecksPassing');
} else {
num = get(get(this, 'Checks').filterBy('Status', 'passing'), 'length');
}
return {
length: num,
};
}),
hasStatus: function(status) {
let num = 0;
switch (status) {
case 'passing':
num = get(this, 'ChecksPassing');
break;
case 'critical':
num = get(this, 'ChecksCritical');
break;
case 'warning':
num = get(this, 'ChecksWarning');
break;
case '': // all
num = 1;
break;
}
return num > 0;
},
});

View File

@ -0,0 +1,23 @@
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
export const PRIMARY_KEY = 'uid';
export const SLUG_KEY = 'ID';
export default Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
Name: attr('string'),
Node: attr('string'),
CreateIndex: attr('number'),
ModifyIndex: attr('number'),
LockDelay: attr('number'),
Behavior: attr('string'),
TTL: attr('number'),
Checks: attr({
defaultValue: function() {
return [];
},
}),
Datacenter: attr('string'),
});

3
ui-v2/app/resolver.js Normal file
View File

@ -0,0 +1,3 @@
import Resolver from 'ember-resolver';
export default Resolver;

45
ui-v2/app/router.js Normal file
View File

@ -0,0 +1,45 @@
import EmberRouter from '@ember/routing/router';
import config from './config/environment';
const Router = EmberRouter.extend({
location: config.locationType,
rootURL: config.rootURL,
});
Router.map(function() {
// Our parent datacenter resource sets the namespace
// for the entire application
this.route('dc', { path: '/:dc' }, function() {
// Services represent a consul service
this.route('services', { path: '/services' }, function() {
// Show an individual service
this.route('show', { path: '/*name' });
});
// Nodes represent a consul node
this.route('nodes', { path: '/nodes' }, function() {
// Show an individual node
this.route('show', { path: '/:name' });
});
// Key/Value
this.route('kv', { path: '/kv' }, function() {
this.route('folder', { path: '/*key' });
this.route('edit', { path: '/*key/edit' });
this.route('create', { path: '/*key/create' });
this.route('root-create', { path: '/create' });
});
// ACLs
this.route('acls', { path: '/acls' }, function() {
this.route('edit', { path: '/:id' });
this.route('create', { path: '/create' });
});
});
// Shows a datacenter picker. If you only have one
// it just redirects you through.
this.route('index', { path: '/' });
// The settings page is global.
this.route('settings', { path: '/settings' });
this.route('notfound', { path: '/*path' });
});
export default Router;

View File

@ -0,0 +1,66 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import { next } from '@ember/runloop';
const $html = document.documentElement;
export default Route.extend({
init: function() {
this._super(...arguments);
},
repo: service('dc'),
actions: {
loading: function(transition, originRoute) {
let dc = null;
if (originRoute.routeName !== 'dc') {
const model = this.modelFor('dc') || { dcs: null, dc: { Name: null } };
dc = get(this, 'repo').getActive(model.dc.Name, model.dcs);
}
hash({
loading: !$html.classList.contains('ember-loading'),
dc: dc,
}).then(model => {
next(() => {
const controller = this.controllerFor('application');
controller.setProperties(model);
transition.promise.finally(function() {
$html.classList.remove('ember-loading');
controller.setProperties({
loading: false,
dc: model.dc,
});
});
});
});
return true;
},
error: function(e, transition) {
let error = {
status: e.code || '',
message: e.message || e.detail || 'Error',
};
if (e.errors && e.errors[0]) {
error = e.errors[0];
error.message = error.title || error.detail || 'Error';
}
if (error.status === '') {
error.message = 'Error';
}
hash({
error: error,
dc: error.status.toString().indexOf('5') !== 0 ? get(this, 'repo').getActive() : null,
})
.then(model => {
next(() => {
this.controllerFor('error').setProperties(model);
});
})
.catch(e => {
next(() => {
this.controllerFor('error').setProperties({ error: error });
});
});
return true;
},
},
});

25
ui-v2/app/routes/dc.js Normal file
View File

@ -0,0 +1,25 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
export default Route.extend({
repo: service('dc'),
settings: service('settings'),
model: function(params) {
const repo = get(this, 'repo');
return hash({
dcs: repo.findAll(),
}).then(function(model) {
return hash({
...model,
...{
dc: repo.findBySlug(params.dc, model.dcs),
},
});
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -0,0 +1,33 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get, set } from '@ember/object';
import WithAclActions from 'consul-ui/mixins/acl/with-actions';
export default Route.extend(WithAclActions, {
templateName: 'dc/acls/edit',
repo: service('acls'),
beforeModel: function() {
get(this, 'repo').invalidate();
},
model: function(params) {
this.item = get(this, 'repo').create();
set(this.item, 'Datacenter', this.modelFor('dc').dc.Name);
return hash({
create: true,
isLoading: false,
item: this.item,
types: ['management', 'client'],
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
deactivate: function() {
if (get(this.item, 'isNew')) {
this.item.destroyRecord();
}
},
});

View File

@ -0,0 +1,22 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithAclActions from 'consul-ui/mixins/acl/with-actions';
export default Route.extend(WithAclActions, {
repo: service('acls'),
settings: service('settings'),
model: function(params) {
return hash({
isLoading: false,
item: get(this, 'repo').findBySlug(params.id, this.modelFor('dc').dc.Name),
types: ['management', 'client'],
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -0,0 +1,26 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithAclActions from 'consul-ui/mixins/acl/with-actions';
export default Route.extend(WithAclActions, {
repo: service('acls'),
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
model: function(params) {
return hash({
isLoading: false,
items: get(this, 'repo').findAllByDatacenter(this.modelFor('dc').dc.Name),
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -0,0 +1,35 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get, set } from '@ember/object';
import WithKvActions from 'consul-ui/mixins/kv/with-actions';
export default Route.extend(WithKvActions, {
templateName: 'dc/kv/edit',
repo: service('kv'),
beforeModel: function() {
get(this, 'repo').invalidate();
},
model: function(params) {
const key = params.key || '/';
const repo = get(this, 'repo');
const dc = this.modelFor('dc').dc.Name;
this.item = repo.create();
set(this.item, 'Datacenter', dc);
return hash({
create: true,
isLoading: false,
item: this.item,
parent: repo.findBySlug(key, dc),
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
deactivate: function() {
if (get(this.item, 'isNew')) {
this.item.destroyRecord();
}
},
});

View File

@ -0,0 +1,38 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithKvActions from 'consul-ui/mixins/kv/with-actions';
import ascend from 'consul-ui/utils/ascend';
export default Route.extend(WithKvActions, {
repo: service('kv'),
sessionRepo: service('session'),
model: function(params) {
const key = params.key;
const dc = this.modelFor('dc').dc.Name;
const repo = get(this, 'repo');
return hash({
isLoading: false,
parent: repo.findBySlug(ascend(key, 1) || '/', dc),
item: repo.findBySlug(key, dc),
}).then(model => {
// TODO: Consider loading this after initial page load
const session = get(model.item, 'Session');
if (session) {
return hash({
...model,
...{
session: get(this, 'sessionRepo').findByKey(session, dc),
},
});
}
return model;
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -0,0 +1,12 @@
// import Route from '@ember/routing/route';
import Route from './index';
export default Route.extend({
templateName: 'dc/kv/index',
beforeModel: function(transition) {
const params = this.paramsFor('dc.kv.folder');
if (params.key === '/' || params.key == null) {
this.transitionTo('dc.kv.index');
}
},
});

View File

@ -0,0 +1,37 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithKvActions from 'consul-ui/mixins/kv/with-actions';
export default Route.extend(WithKvActions, {
repo: service('kv'),
model: function(params) {
const key = params.key || '/';
const dc = this.modelFor('dc').dc.Name;
const repo = get(this, 'repo');
return hash({
isLoading: false,
parent: repo.findBySlug(key, dc),
})
.then(function(model) {
return hash({
...model,
...{
items: repo.findAllBySlug(get(model.parent, 'Key'), dc),
},
});
})
.catch(e => {
if (e.errors && e.errors[0] && e.errors[0].status == '404') {
this.transitionTo('dc.kv.index');
return;
}
throw e;
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -0,0 +1,3 @@
import Route from './create';
export default Route.extend();

View File

@ -0,0 +1,23 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
export default Route.extend({
repo: service('nodes'),
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
model: function(params) {
return hash({
items: get(this, 'repo').findAllByDatacenter(this.modelFor('dc').dc.Name),
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -0,0 +1,66 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get, set } from '@ember/object';
import distance from 'consul-ui/utils/distance';
import tomographyFactory from 'consul-ui/utils/tomography';
import WithFeedback from 'consul-ui/mixins/with-feedback';
const tomography = tomographyFactory(distance);
export default Route.extend(WithFeedback, {
repo: service('nodes'),
sessionRepo: service('session'),
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
model: function(params) {
const dc = this.modelFor('dc').dc.Name;
const repo = get(this, 'repo');
const sessionRepo = get(this, 'sessionRepo');
return hash({
item: repo.findBySlug(params.name, dc),
}).then(function(model) {
// TODO: Consider loading this after initial page load
const coordinates = get(model.item, 'Coordinates');
return hash({
...model,
...{
tomography:
get(coordinates, 'length') > 1
? tomography(params.name, coordinates.map(item => get(item, 'data')))
: null,
items: get(model.item, 'Services'),
sessions: sessionRepo.findByNode(get(model.item, 'Node'), dc),
},
});
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
actions: {
invalidateSession: function(item) {
const dc = this.modelFor('dc').dc.Name;
const controller = this.controller;
const repo = get(this, 'sessionRepo');
get(this, 'feedback').execute(
() => {
const node = get(item, 'Node');
return repo.remove(item).then(() => {
return repo.findByNode(node, dc).then(function(sessions) {
set(controller, 'sessions', sessions);
});
});
},
`The session was invalidated.`,
`There was an error invalidating the session.`
);
},
},
});

View File

@ -0,0 +1,24 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
export default Route.extend({
repo: service('services'),
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
model: function(params) {
const repo = get(this, 'repo');
return hash({
items: repo.findAllByDatacenter(this.modelFor('dc').dc.Name),
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -0,0 +1,31 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
export default Route.extend({
repo: service('services'),
queryParams: {
s: {
as: 'filter',
replace: true,
},
},
model: function(params) {
const repo = get(this, 'repo');
return hash({
item: repo.findBySlug(params.name, this.modelFor('dc').dc.Name),
}).then(function(model) {
return {
...model,
...{
items: model.item.Nodes,
},
};
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

16
ui-v2/app/routes/index.js Normal file
View File

@ -0,0 +1,16 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
export default Route.extend({
repo: service('dc'),
model: function(params) {
return hash({
item: get(this, 'repo').getActive(),
});
},
afterModel: function({ item }, transition) {
this.transitionTo('dc.services', get(item, 'Name'));
},
});

View File

@ -0,0 +1,10 @@
import Route from '@ember/routing/route';
import Error from '@ember/error';
export default Route.extend({
beforeModel: function() {
const e = new Error('Page not found');
e.code = 404;
throw e;
},
});

View File

@ -0,0 +1,47 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { hash } from 'rsvp';
import { get } from '@ember/object';
import WithFeedback from 'consul-ui/mixins/with-feedback';
export default Route.extend(WithFeedback, {
dcRepo: service('dc'),
repo: service('settings'),
model: function(params) {
return hash({
item: get(this, 'repo').findAll(),
dcs: get(this, 'dcRepo').findAll(),
}).then(model => {
return hash({
...model,
...{
dc: get(this, 'dcRepo').getActive(null, model.dcs),
},
});
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
actions: {
update: function(item) {
get(this, 'feedback').execute(
() => {
return get(this, 'repo').persist(item);
},
`Your settings were saved.`,
`There was an error saving your settings.`
);
},
delete: function(key) {
get(this, 'feedback').execute(
() => {
return get(this, 'repo').remove(key);
},
`You settings have been reset.`,
`There was an error resetting your settings.`
);
},
},
});

View File

@ -0,0 +1,6 @@
import Serializer from './application';
import { PRIMARY_KEY } from 'consul-ui/models/acl';
export default Serializer.extend({
primaryKey: PRIMARY_KEY,
});

View File

@ -0,0 +1,21 @@
import Serializer from 'ember-data/serializers/rest';
export default Serializer.extend({
// this could get confusing if you tried to override
// say `normalizeQueryResponse`
// TODO: consider creating a method for each one of the `normalize...Response` family
normalizeResponse: function(store, primaryModelClass, payload, id, requestType) {
return this._super(
store,
primaryModelClass,
{
[primaryModelClass.modelName]: this.normalizePayload(payload, id, requestType),
},
id,
requestType
);
},
normalizePayload: function(payload, id, requestType) {
return payload;
},
});

View File

@ -0,0 +1,6 @@
import Serializer from './application';
import { PRIMARY_KEY } from 'consul-ui/models/coordinate';
export default Serializer.extend({
primaryKey: PRIMARY_KEY,
});

View File

@ -0,0 +1,17 @@
import Serializer from './application';
import { get } from '@ember/object';
export default Serializer.extend({
primaryKey: 'Name',
normalizePayload: function(payload, id, requestType) {
switch (requestType) {
case 'findAll':
return payload.map(item => {
return {
[get(this, 'primaryKey')]: item,
};
});
}
return payload;
},
});

View File

@ -0,0 +1,6 @@
import Serializer from './application';
import { PRIMARY_KEY } from 'consul-ui/models/kv';
export default Serializer.extend({
primaryKey: PRIMARY_KEY,
});

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