UI: Service Instances (#5326)

This gives more prominence to 'Service Instances' as opposed to 'Services'. It also begins to surface Connect related 'nouns' such as 'Proxies' and 'Upstreams' and begins to interconnect them giving more visibility to operators.

Various smaller changes:

1. Move healthcheck-status component to healthcheck-output
2. Create a new healthcheck-status component for showing the number of
checks plus its icon
3. Create a new healthcheck-info component to group multiple statuses
plus a different view if there are no checks
4. Componentize tag-list
This commit is contained in:
John Cowen 2019-02-21 13:10:53 +00:00 committed by John Cowen
parent cfa4bc264e
commit 355f034822
75 changed files with 919 additions and 370 deletions

View File

@ -0,0 +1,20 @@
import Adapter from './application';
import { PRIMARY_KEY, SLUG_KEY } from 'consul-ui/models/proxy';
import { OK as HTTP_OK } from 'consul-ui/utils/http/status';
export default Adapter.extend({
urlForQuery: function(query, modelName) {
if (typeof query.id === 'undefined') {
throw new Error('You must specify an id');
}
// https://www.consul.io/api/catalog.html#list-nodes-for-connect-capable-service
return this.appendURL('catalog/connect', [query.id], this.cleanQuery(query));
},
handleResponse: function(status, headers, payload, requestData) {
let response = payload;
if (status === HTTP_OK) {
const url = this.parseURL(requestData.url);
response = this.handleBatchResponse(url, response, PRIMARY_KEY, SLUG_KEY);
}
return this._super(status, headers, response, requestData);
},
});

View File

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

View File

@ -0,0 +1,36 @@
import Component from '@ember/component';
import { get } from '@ember/object';
export default Component.extend({
// TODO: Could potentially do this on attr change
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,5 @@
import Component from '@ember/component';
export default Component.extend({
classNames: ['healthcheck-output'],
});

View File

@ -1,5 +1,12 @@
import Component from '@ember/component';
import { get, computed } from '@ember/object';
export default Component.extend({
classNames: ['healthcheck-status'],
tagName: '',
count: computed('value', function() {
const value = get(this, 'value');
if (Array.isArray(value)) {
return value.length;
}
return value;
}),
});

View File

@ -3,4 +3,5 @@ import Component from '@ember/component';
export default Component.extend({
name: 'tab',
tagName: 'nav',
classNames: ['tab-nav'],
});

View File

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

View File

@ -0,0 +1,17 @@
import Controller from '@ember/controller';
import { set } from '@ember/object';
export default Controller.extend({
setProperties: function() {
this._super(...arguments);
// This method is called immediately after `Route::setupController`, and done here rather than there
// as this is a variable used purely for view level things, if the view was different we might not
// need this variable
set(this, 'selectedTab', 'service-checks');
},
actions: {
change: function(e) {
set(this, 'selectedTab', e.target.value);
},
},
});

View File

@ -1,38 +1,39 @@
import Controller from '@ember/controller';
import { get, 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';
import { get, set, computed } from '@ember/object';
import { inject as service } from '@ember/service';
import WithSearching from 'consul-ui/mixins/with-searching';
export default Controller.extend(WithSearching, WithHealthFiltering, {
export default Controller.extend(WithSearching, {
dom: service('dom'),
init: function() {
this.searchParams = {
healthyServiceNode: 's',
unhealthyServiceNode: 's',
serviceInstance: 's',
};
this._super(...arguments);
},
searchableHealthy: computed('healthy', function() {
return get(this, 'searchables.healthyServiceNode')
.add(get(this, 'healthy'))
.search(get(this, this.searchParams.healthyServiceNode));
setProperties: function() {
this._super(...arguments);
// This method is called immediately after `Route::setupController`, and done here rather than there
// as this is a variable used purely for view level things, if the view was different we might not
// need this variable
set(this, 'selectedTab', 'instances');
},
searchable: computed('items', function() {
return get(this, 'searchables.serviceInstance')
.add(get(this, 'items'))
.search(get(this, this.searchParams.serviceInstance));
}),
searchableUnhealthy: computed('unhealthy', function() {
return get(this, 'searchables.unhealthyServiceNode')
.add(get(this, 'unhealthy'))
.search(get(this, this.searchParams.unhealthyServiceNode));
}),
unhealthy: computed('filtered', function() {
return get(this, 'filtered').filter(function(item) {
return sumOfUnhealthy(item.Checks) > 0;
actions: {
change: function(e) {
set(this, 'selectedTab', e.target.value);
// Ensure tabular-collections sizing is recalculated
// now it is visible in the DOM
get(this, 'dom')
.components('.tab-section input[type="radio"]:checked + div table')
.forEach(function(item) {
if (typeof item.didAppear === 'function') {
item.didAppear();
}
});
}),
healthy: computed('filtered', function() {
return get(this, 'filtered').filter(function(item) {
return sumOfUnhealthy(item.Checks) === 0;
});
}),
filter: function(item, { s = '', status = '' }) {
return hasStatus(get(item, 'Checks'), status);
},
},
});

View File

@ -22,8 +22,7 @@ export function initialize(application) {
kv: kv(filterable),
healthyNode: node(filterable),
unhealthyNode: node(filterable),
healthyServiceNode: serviceNode(filterable),
unhealthyServiceNode: serviceNode(filterable),
serviceInstance: serviceNode(filterable),
nodeservice: nodeService(filterable),
service: service(filterable),
};

12
ui-v2/app/models/proxy.js Normal file
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 = 'ID';
export default Model.extend({
[PRIMARY_KEY]: attr('string'),
[SLUG_KEY]: attr('string'),
ServiceName: attr('string'),
ServiceID: attr('string'),
ServiceProxyDestination: attr('string'),
});

View File

@ -18,6 +18,9 @@ export const routes = {
show: {
_options: { path: '/:name' },
},
instance: {
_options: { path: '/:name/:id' },
},
},
// Nodes represent a consul node
nodes: {

View File

@ -0,0 +1,29 @@
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('repository/service'),
proxyRepo: service('repository/proxy'),
model: function(params) {
const repo = get(this, 'repo');
const proxyRepo = get(this, 'proxyRepo');
const dc = this.modelFor('dc').dc.Name;
return hash({
item: repo.findInstanceBySlug(params.id, params.name, dc),
}).then(function(model) {
return hash({
proxy:
get(service, 'Kind') !== 'connect-proxy'
? proxyRepo.findInstanceBySlug(params.id, params.name, dc)
: null,
...model,
});
});
},
setupController: function(controller, model) {
this._super(...arguments);
controller.setProperties(model);
},
});

View File

@ -19,6 +19,7 @@ export default Route.extend({
return {
...model,
...{
// Nodes happen to be the ServiceInstances here
items: model.item.Nodes,
},
};

View File

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

View File

@ -70,7 +70,9 @@ export default Service.extend({
// with traditional/standard web components you wouldn't actually need this
// method as you could just get to their methods from the dom element
component: function(selector, context) {
// TODO: support passing a dom element, when we need to do that
if (typeof selector !== 'string') {
return $_(selector);
}
return $_(this.element(selector, context));
},
components: function(selector, context) {

View File

@ -0,0 +1,33 @@
import RepositoryService from 'consul-ui/services/repository';
import { PRIMARY_KEY } from 'consul-ui/models/proxy';
import { get } from '@ember/object';
const modelName = 'proxy';
export default RepositoryService.extend({
getModelName: function() {
return modelName;
},
getPrimaryKey: function() {
return PRIMARY_KEY;
},
findAllBySlug: function(slug, dc, configuration = {}) {
const query = {
id: slug,
dc: dc,
};
if (typeof configuration.cursor !== 'undefined') {
query.index = configuration.cursor;
}
return this.get('store').query(this.getModelName(), query);
},
findInstanceBySlug: function(id, slug, dc, configuration) {
return this.findAllBySlug(slug, dc, configuration).then(function(items) {
if (get(items, 'length') > 0) {
const instance = items.findBy('ServiceProxyDestination', id);
if (instance) {
return instance;
}
}
return;
});
},
});

View File

@ -19,4 +19,23 @@ export default RepositoryService.extend({
return service;
});
},
findInstanceBySlug: function(id, slug, dc, configuration) {
return this.findBySlug(slug, dc, configuration).then(function(item) {
const i = item.Nodes.findIndex(function(item) {
return item.Service.ID === id;
});
if (i !== -1) {
const service = item.Nodes[i].Service;
service.Node = item.Nodes[i].Node;
service.ServiceChecks = item.Nodes[i].Checks.filter(function(item) {
return item.ServiceID != '';
});
service.NodeChecks = item.Nodes[i].Checks.filter(function(item) {
return item.ServiceID == '';
});
return service;
}
// TODO: probably need to throw a 404 here?
});
},
});

View File

@ -10,6 +10,15 @@
display: flex;
align-items: flex-start;
}
%app-view header dl {
float: left;
margin-top: 25px;
margin-right: 50px;
margin-bottom: 20px;
}
%app-view header dt {
font-weight: bold;
}
/* units */
%app-view {
margin-top: 50px;

View File

@ -1,10 +1,28 @@
%app-view h2,
%app-view header > div:last-of-type {
border-bottom: $decor-border-100;
}
%app-view header > div:last-of-type,
%app-view h2 {
border-color: $keyline-light;
border-bottom: $decor-border-200;
}
@media #{$--horizontal-selects} {
%app-view header h1 {
border-bottom: $decor-border-200;
}
}
@media #{$--lt-horizontal-selects} {
%app-view header > div > div:last-child {
border-bottom: $decor-border-200;
}
}
%app-view header > div > div:last-child,
%app-view header h1,
%app-view h2 {
border-color: $gray-200;
}
// We know that any sibling navs might have a top border
// by default. As its squashed up to a h1, in this
// case hide its border to avoid double border
@media #{$--horizontal-selects} {
%app-view header h1 ~ nav {
border-top: 0 !important;
}
}
%app-content div > dl > dd {
color: $gray-400;

View File

@ -1,9 +1,18 @@
%breadcrumbs a {
%breadcrumbs li > * {
@extend %with-chevron;
}
%breadcrumbs li > strong::before {
color: $gray-300;
}
%breadcrumbs li > a::before {
color: rgba($color-action, 0.5);
}
%breadcrumbs ol {
list-style-type: none;
}
%breadcrumbs a {
color: $color-action;
}
%breadcrumbs strong {
color: $gray-400;
}

View File

@ -24,7 +24,7 @@ form table,
%app-content form dl {
@extend %form-row;
}
%app-content [role='radiogroup'] {
%app-content form:not(.filter-bar) [role='radiogroup'] {
@extend %radio-group;
}
%radio-group label {

View File

@ -0,0 +1,12 @@
@import './healthcheck-info/index';
@import './icons/index';
tr dl {
@extend %healthcheck-info;
}
td span.zero {
@extend %with-no-healthchecks;
// TODO: Why isn't this is layout?
display: block;
text-indent: 20px;
color: $gray-400;
}

View File

@ -0,0 +1,32 @@
%healthcheck-info {
display: flex;
height: 100%;
float: left;
}
%healthcheck-info > * {
display: block;
}
%healthcheck-info dt.zero {
display: none;
}
%healthcheck-info dd.zero {
visibility: hidden;
}
%healthcheck-info dt {
text-indent: -9000px;
}
%healthcheck-info dt.warning {
overflow: visible;
}
%healthcheck-info dt.warning::before {
top: 7px;
}
%healthcheck-info dt.warning::after {
left: -2px;
top: -1px;
}
%healthcheck-info dd {
box-sizing: content-box;
margin-left: 22px;
padding-right: 10px;
}

View File

@ -0,0 +1,21 @@
%healthcheck-info dt.passing {
@extend %with-passing;
}
%healthcheck-info dt.warning {
@extend %with-warning;
}
%healthcheck-info dt.critical {
@extend %with-critical;
}
%healthcheck-info dt.passing,
%healthcheck-info dt.passing + dd {
color: $color-success;
}
%healthcheck-info dt.warning,
%healthcheck-info dt.warning + dd {
color: $color-alert;
}
%healthcheck-info dt.critical,
%healthcheck-info dt.critical + dd {
color: $color-failure;
}

View File

@ -1,32 +1,32 @@
@import './healthcheck-status/index';
@import './healthcheck-output/index';
@import './icons/index';
.healthcheck-status {
@extend %healthcheck-status;
.healthcheck-output {
@extend %healthcheck-output;
}
%healthcheck-status.passing {
%healthcheck-output.passing {
@extend %with-passing;
}
%healthcheck-status.warning {
%healthcheck-output.warning {
@extend %with-warning;
}
%healthcheck-status.critical {
%healthcheck-output.critical {
@extend %with-critical;
}
@media #{$--lt-spacious-healthcheck-status} {
.healthcheck-status button.copy-btn {
@media #{$--lt-spacious-healthcheck-output} {
.healthcheck-output button.copy-btn {
margin-top: -11px;
margin-right: -18px;
padding: 0;
width: 20px;
visibility: hidden;
}
%healthcheck-status {
%healthcheck-output {
padding-left: 30px;
padding-top: 10px;
padding-bottom: 15px;
padding-right: 13px;
}
%healthcheck-status::before {
%healthcheck-output::before {
width: 15px !important;
height: 15px !important;
left: 9px;

View File

@ -0,0 +1,2 @@
@import './skin';
@import './layout';

View File

@ -1,4 +1,4 @@
%healthcheck-status::before {
%healthcheck-output::before {
background-size: 55%;
width: 25px !important;
height: 25px !important;
@ -6,25 +6,25 @@
top: 20px !important;
margin-top: 0 !important;
}
%healthcheck-status.warning::before {
%healthcheck-output.warning::before {
background-size: 100%;
}
%healthcheck-status {
%healthcheck-output {
padding: 20px 24px;
padding-bottom: 26px;
padding-left: 57px;
margin-bottom: 24px;
position: relative;
}
%healthcheck-status pre {
%healthcheck-output pre {
padding: 12px;
}
%healthcheck-status .with-feedback {
%healthcheck-output .with-feedback {
float: right;
}
%healthcheck-status dt {
%healthcheck-output dt {
margin-bottom: 0.2em;
}
%healthcheck-status dd:first-of-type {
%healthcheck-output dd:first-of-type {
margin-bottom: 0.6em;
}

View File

@ -1,35 +1,35 @@
%healthcheck-status {
%healthcheck-output {
border-width: 1px;
}
%healthcheck-status,
%healthcheck-status pre {
%healthcheck-output,
%healthcheck-output pre {
border-radius: $decor-radius-100;
}
%healthcheck-status dd:first-of-type {
%healthcheck-output dd:first-of-type {
color: $gray-400;
}
%healthcheck-status pre {
%healthcheck-output pre {
background-color: $black;
color: $white;
}
%healthcheck-status.passing {
%healthcheck-output.passing {
/* TODO: this should be a frame-gray */
// @extend %frame-green-500;
color: $gray-900;
border-color: $gray-200;
border-style: solid;
}
%healthcheck-status.warning {
%healthcheck-output.warning {
@extend %frame-yellow-500;
color: $gray-900;
}
%healthcheck-status.critical {
%healthcheck-output.critical {
@extend %frame-red-500;
color: $gray-900;
}
%healthcheck-status.passing::before {
%healthcheck-output.passing::before {
background-color: $color-success !important;
}
%healthcheck-status.critical::before {
%healthcheck-output.critical::before {
background-color: $color-danger !important;
}

View File

@ -93,12 +93,11 @@
}
%with-chevron::before {
@extend %pseudo-icon;
background-image: url('data:image/svg+xml;charset=UTF-8,<svg width="6" height="9" xmlns="http://www.w3.org/2000/svg"><path fill="%230068FF" d="M5.771 9H3.527L.334 4.834 3.527.674h2.244l-3.193 4.16z" opacity=".33"/></svg>');
content: '';
width: 6px;
height: 9px;
background-color: transparent;
left: 0;
margin-top: -4px;
background-color: $color-transparent;
font-size: 0.7rem;
}
%with-folder::before {
@extend %pseudo-icon;

View File

@ -16,7 +16,9 @@
@import './app-view';
@import './product';
@import './healthcheck-status';
@import './tag-list';
@import './healthcheck-output';
@import './healthcheck-info';
@import './healthchecked-resource';
@import './freetext-filter';
@import './filter-bar';

View File

@ -1,4 +1,5 @@
@import './pill/index';
td strong {
td strong,
%tag-list span {
@extend %pill;
}

View File

@ -1,41 +1,30 @@
@import './icons/index';
@import './table/index';
html.template-service.template-list td:first-child a span,
html.template-node.template-show #services td:first-child a span,
html.template-service.template-show #instances td:first-child a span {
@extend %with-external-source-icon;
float: left;
margin-right: 10px;
margin-top: 2px;
}
/* This nudges the th in for the external source icons */
html.template-node.template-show #services th:first-child,
html.template-service.template-show #instances th:first-child,
html.template-service.template-list main th:first-child {
text-indent: 28px;
}
td.folder {
@extend %with-folder;
}
td dt.passing {
@extend %with-passing;
}
td dt.warning {
@extend %with-warning;
}
td dt.critical {
@extend %with-critical;
}
td span.zero {
@extend %with-no-healthchecks;
display: block;
text-indent: 20px;
color: $gray-400;
}
table:not(.sessions) tr {
cursor: pointer;
}
table:not(.sessions) td:first-child {
padding: 0;
}
td dt.passing,
td dt.passing + dd {
color: $color-success;
}
td dt.warning,
td dt.warning + dd {
color: $color-alert;
}
td dt.critical,
td dt.critical + dd {
color: $color-failure;
}
/* Header Tooltips/Icon*/
th {
overflow: visible;

View File

@ -31,7 +31,7 @@ table th {
padding-bottom: 0.6em;
}
table td,
table td a {
table td:first-child a {
padding: 0.9em 0;
}
table th,
@ -50,44 +50,6 @@ td:not(.actions) a {
overflow: hidden;
}
// TODO: this isn't specific to table
// these are the node health 3 column display
tr > * dl {
float: left;
}
td dl {
height: 100%;
}
td dl {
display: flex;
}
td dl > * {
display: block;
}
td dt.zero {
display: none;
}
td dd.zero {
visibility: hidden;
}
td dt {
text-indent: -9000px;
}
td dt.warning {
overflow: visible;
}
td dt.warning::before {
top: 7px;
}
td dt.warning::after {
left: -2px;
top: -1px;
}
td dd {
box-sizing: content-box;
margin-left: 22px;
padding-right: 10px;
}
/* hide actions on narrow screens, you can always click in do everything from there */
@media #{$--lt-wide-table} {
tr > .actions {
@ -96,6 +58,8 @@ td dd {
}
/* ideally these would be in route css files, but left here as they */
/* accomplish the same thing (hide non-essential columns for tables) */
/* TODO: Move these to component/table.scss for the moment */
/* Also mixed with things in component/tabular-collection.scss move those also */
@media #{$--lt-medium-table} {
/* Policy > Datacenters */
html.template-policy.template-list tr > :nth-child(2) {

View File

@ -1,5 +1,5 @@
@import './tabs/index';
main header nav:last-of-type:not(:first-of-type) {
.tab-nav {
@extend %tab-nav;
}
.tab-section {

View File

@ -2,6 +2,9 @@
/* this keeps in-tab-section toolbars flush to the top, see Node Detail > Services */
margin-top: 0 !important;
}
%tab-nav {
clear: both;
}
@media #{$--horizontal-tabs} {
%tab-nav ul {
display: flex;

View File

@ -1,3 +1,12 @@
%tab-nav {
/* %frame-gray-something */
border-bottom: $decor-border-100;
border-top: $decor-border-200;
}
%tab-nav {
/* %frame-gray-something */
border-color: $gray-200;
}
%tab-nav label {
cursor: pointer;
}

View File

@ -35,17 +35,16 @@ table.dom-recycling {
/* using: */
/* calc(<100% divided by number of non-fixed width cells> - <sum of widths of fixed cells divided by number of non-fixed width cells>) */
html.template-service.template-list td:first-child a span,
html.template-node.template-show #services td:first-child a span {
@extend %with-external-source-icon;
float: left;
margin-right: 10px;
margin-top: 2px;
}
/*TODO: trs only live in tables, get rid of table */
html.template-service.template-list main table tr {
@extend %services-row;
}
html.template-service.template-show #instances table tr {
@extend %instances-row;
}
html.template-instance.template-show #upstreams table tr {
@extend %upstreams-row;
}
html.template-intention.template-list main table tr {
@extend %intentions-row;
}
@ -146,6 +145,12 @@ html.template-node.template-show main table.sessions tr {
html.template-token.template-list main table tr td.me ~ td:nth-of-type(5) {
display: none;
}
html.template-service.template-show #instances tr > :nth-child(3) {
display: none;
}
%instances-row > * {
width: calc(100% / 4);
}
}
%kvs-row > *:first-child {
@ -155,7 +160,7 @@ html.template-node.template-show main table.sessions tr {
@extend %table-actions;
}
%node-services-row > * {
width: 33%;
width: calc(100% / 3);
}
%policies-row > * {
width: calc(33% - 20px);
@ -172,3 +177,9 @@ html.template-node.template-show main table.sessions tr {
%services-row > * {
width: auto;
}
%instances-row > * {
width: calc(100% / 5);
}
%upstreams-row > * {
width: calc(100% / 3);
}

View File

@ -0,0 +1,5 @@
@import './tag-list/index';
.tag-list,
td.tags {
@extend %tag-list;
}

View File

@ -0,0 +1,2 @@
@import './skin';
@import './layout';

View File

@ -0,0 +1,10 @@
%tag-list dt {
display: none;
}
// TODO: Currently this is here to overwrite
// the default definition list layout used in edit pages
// ideally we'd be more specific with those to say
// only add padding to dl's in edit pages
%tag-list dd {
padding-left: 0;
}

View File

@ -36,10 +36,10 @@ h1,
h2,
%header-nav,
%healthchecked-resource header span,
%healthcheck-status dt,
%healthcheck-output dt,
%copy-button,
%app-content div > dl > dt,
td a {
td:first-child a {
font-weight: $typo-weight-semibold;
}
%form-element > span,
@ -51,7 +51,7 @@ caption {
font-weight: $typo-weight-semibold !important;
}
th,
%breadcrumbs a,
%breadcrumbs li > *,
%action-group-action,
%tab-nav,
%tooltip-bubble {

View File

@ -1,17 +0,0 @@
@import '../../../components/pill/index';
html.template-service.template-show main dl {
display: flex;
margin-bottom: 1.4em;
}
html.template-service.template-show main dt {
display: none;
}
// TODO: Generalize this, also see nodes/index
html.template-service.template-list td.tags span,
html.template-service.template-show main dd span {
@extend %pill;
}
html.template-node.template-show #services th:first-child,
html.template-service.template-list main th:first-child {
text-indent: 28px;
}

View File

@ -26,8 +26,8 @@ $--lt-wide-footer: '(max-width: 420px)';
$--spacious-page-header: '(min-width: 850px)';
$--lt-spacious-page-header: '(max-width: 849px)';
$--spacious-healthcheck-status: '(min-width: 421px)';
$--lt-spacious-healthcheck-status: '(max-width: 420px)';
$--spacious-healthcheck-output: '(min-width: 421px)';
$--lt-spacious-healthcheck-output: '(max-width: 420px)';
$--wide-form: '(min-width: 421px)';
$--lt-wide-form: '(max-width: 420px)';

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>
{{healthcheck-status width=passingWidth name='passing' value=passing}}
{{healthcheck-status width=warningWidth name='warning' value=warning}}
{{healthcheck-status width=criticalWidth name='critical' value=critical}}
</dl>
{{/if}}

View File

@ -0,0 +1,5 @@
<ul data-test-node-healthchecks>
{{#each (sort-by (action 'sortChecksByImportance') items) as |check| }}
{{healthcheck-output data-test-node-healthcheck=check.Name tagName='li' name=check.Name class=check.Status status=check.Status notes=check.Notes output=check.Output}}
{{/each}}
</ul>

View File

@ -0,0 +1,25 @@
{{#feedback-dialog type='inline'}}
{{#block-slot 'action' as |success error|}}
{{#copy-button success=(action success) error=(action error) clipboardText=output title='copy output to clipboard'}}
Copy Output
{{/copy-button}}
{{/block-slot}}
{{#block-slot 'success' as |transition|}}
<p class={{transition}}>
Copied IP Address!
</p>
{{/block-slot}}
{{#block-slot 'error' as |transition|}}
<p class={{transition}}>
Sorry, something went wrong!
</p>
{{/block-slot}}
{{/feedback-dialog}}
<dl>
<dt>{{name}}</dt>
<dd>{{notes}}</dd>
<dt>Output</dt>
<dd>
<pre><code>{{output}}</code></pre>
</dd>
</dl>

View File

@ -1,25 +1,3 @@
{{#feedback-dialog type='inline'}}
{{#block-slot 'action' as |success error|}}
{{#copy-button success=(action success) error=(action error) clipboardText=output title='copy output to clipboard'}}
Copy Output
{{/copy-button}}
{{/block-slot}}
{{#block-slot 'success' as |transition|}}
<p class={{transition}}>
Copied IP Address!
</p>
{{/block-slot}}
{{#block-slot 'error' as |transition|}}
<p class={{transition}}>
Sorry, something went wrong!
</p>
{{/block-slot}}
{{/feedback-dialog}}
<dl>
<dt>{{name}}</dt>
<dd>{{notes}}</dd>
<dt>Output</dt>
<dd>
<pre><code>{{output}}</code></pre>
</dd>
</dl>
{{!-- we use concat here to avoid ember adding returns between words, which causes a layout issue--}}
<dt title="{{capitalize name}}" class="{{name}}{{if (lt count 1) ' zero'}}">{{ concat 'Healthchecks ' (capitalize name) }}</dt>
<dd title="{{capitalize name}}" class={{if (lt count 1) 'zero'}} style={{width}}>{{format-number count}}</dd>

View File

@ -0,0 +1,8 @@
{{#if (gt items.length 0)}}
<dt class="tags">Tags</dt>
<dd data-test-tags class="tags">
{{#each items as |item|}}
<span>{{item}}</span>
{{/each}}
</dd>
{{/if}}

View File

@ -1,9 +1,5 @@
{{#if (gt item.Checks.length 0) }}
<ul data-test-node-healthchecks>
{{#each (sort-by (action 'sortChecksByImportance') item.Checks) as |check| }}
{{healthcheck-status data-test-node-healthcheck=check.Name tagName='li' name=check.Name class=check.Status status=check.Status notes=check.Notes output=check.Output}}
{{/each}}
</ul>
{{healthcheck-list items=item.Checks}}
{{else}}
<p>
This node has no health checks.

View File

@ -19,7 +19,7 @@
<td data-test-service-name="{{item.Service}}">
<a href={{href-to 'dc.services.show' item.Service}}>
<span data-test-external-source="{{service/external-source item}}" style={{{ concat 'background-image: ' (css-var (concat '--' (service/external-source item) '-color-svg') 'none')}}}></span>
{{item.Service}}{{#if (not-eq item.ID item.Service) }}<em data-test-service-id="{{item.ID}}">({{item.ID}})</em>{{/if}}
{{item.Service}}{{#if (not-eq item.ID item.Service) }}&nbsp;<em data-test-service-id="{{item.ID}}">({{item.ID}})</em>{{/if}}
</a>
</td>
<td data-test-service-port="{{item.Port}}" class="port">

View File

@ -0,0 +1,55 @@
{{#if (gt items.length 0) }}
<input type="checkbox" id="toolbar-toggle" />
<form class="filter-bar">
{{freetext-filter searchable=searchable value=s placeholder="Search"}}
</form>
{{/if}}
{{#changeable-set dispatcher=searchable}}
{{#block-slot 'set' as |filtered|}}
{{#tabular-collection
data-test-instances
items=filtered as |item index|
}}
{{#block-slot 'header'}}
<th>ID</th>
<th>Node</th>
<th>Address</th>
<th>Node Checks</th>
<th>Service Checks</th>
{{/block-slot}}
{{#block-slot 'row'}}
<td data-test-id="{{item.Service.ID}}">
<a href={{href-to 'dc.services.instance' item.Service.Service (or item.Service.ID item.Service.Service )}}>
<span data-test-external-source="{{service/external-source item.Service}}" style={{{ concat 'background-image: ' (css-var (concat '--' (service/external-source item.Service) '-color-svg') 'none')}}}></span>
{{ or item.Service.ID item.Service.Service }}
</a>
</td>
<td data-test-node>
<a href={{href-to 'dc.nodes.show' item.Node.Node}}>{{item.Node.Node}}</a>
</td>
<td data-test-address>
{{item.Service.Address}}:{{item.Service.Port}}
</td>
<td>
{{#with (reject-by 'ServiceID' '' item.Checks) as |checks|}}
{{healthcheck-info
passing=(filter-by 'Status' 'passing' checks) warning=(filter-by 'Status' 'warning' checks) critical=(filter-by 'Status' 'critical' checks)
}}
{{/with}}
</td>
<td>
{{#with (filter-by 'ServiceID' '' item.Checks) as |checks|}}
{{healthcheck-info
passing=(filter-by 'Status' 'passing' checks) warning=(filter-by 'Status' 'warning' checks) critical=(filter-by 'Status' 'critical' checks)
}}
{{/with}}
</td>
{{/block-slot}}
{{/tabular-collection}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no services.
</p>
{{/block-slot}}
{{/changeable-set}}

View File

@ -0,0 +1,8 @@
{{#if (gt item.NodeChecks.length 0) }}
{{healthcheck-list items=item.NodeChecks}}
{{else}}
<p>
This instance has no node health checks.
</p>
{{/if}}

View File

@ -0,0 +1,8 @@
{{#if (gt item.ServiceChecks.length 0) }}
{{healthcheck-list items=item.ServiceChecks}}
{{else}}
<p>
This instance has no service health checks.
</p>
{{/if}}

View File

@ -0,0 +1,7 @@
{{#if (gt item.Tags.length 0) }}
{{tag-list items=item.Tags}}
{{else}}
<p>
There are no tags.
</p>
{{/if}}

View File

@ -0,0 +1,27 @@
{{#if (gt item.Proxy.Upstreams.length 0) }}
{{#tabular-collection
data-test-upstreams
items=item.Proxy.Upstreams as |item index|
}}
{{#block-slot 'header'}}
<th>Destination Name</th>
<th>Destination Type</th>
<th>Local Bind Port</th>
{{/block-slot}}
{{#block-slot 'row'}}
<td data-test-destination-name>
<a>{{item.DestinationName}}</a>
</td>
<td data-test-destination-type>
{{item.DestinationType}}
</td>
<td data-test-local-bind-port>
{{item.LocalBindPort}}
</td>
{{/block-slot}}
{{/tabular-collection}}
{{else}}
<p>
There are no upstreams.
</p>
{{/if}}

View File

@ -35,18 +35,10 @@
</a>
</td>
<td style={{totalWidth}}>
{{#if (and (lt item.ChecksPassing 1) (lt item.ChecksWarning 1) (lt item.ChecksCritical 1) )}}
<span title="No Healthchecks" class="zero">0</span>
{{else}}
<dl>
<dt title="Passing" class="passing{{if (lt item.ChecksPassing 1) ' zero'}}">Healthchecks Passing</dt>
<dd title="Passing" class={{if (lt item.ChecksPassing 1) 'zero'}} style={{passingWidth}}>{{format-number item.ChecksPassing}}</dd>
<dt title="Warning" class="warning{{if (lt item.ChecksWarning 1) ' zero'}}">Healthchecks Warning</dt>
<dd title="Warning" class={{if (lt item.ChecksWarning 1) 'zero'}} style={{warningWidth}}>{{format-number item.ChecksWarning}}</dd>
<dt title="Critical" class="critical{{if (lt item.ChecksCritical 1) ' zero'}}">Healthchecks Critical</dt>
<dd title="Critical" class={{if (lt item.ChecksCritical 1) 'zero'}} style={{criticalWidth}}>{{format-number item.ChecksCritical}}</dd>
</dl>
{{/if}}
{{healthcheck-info
passing=item.ChecksPassing warning=item.ChecksWarning critical=item.ChecksCritical
passingWidth=passingWidth warningWidth=warningWidth criticalWidth=criticalWidth
}}
</td>
<td class="tags" style={{remainingWidth}}>
{{#if (gt item.Tags.length 0)}}

View File

@ -0,0 +1,72 @@
{{#app-view class="instance show"}}
{{#block-slot 'breadcrumbs'}}
<ol>
<li><a data-test-back href={{href-to 'dc.services'}}>All Services</a></li>
<li><a data-test-back href={{href-to 'dc.services.show'}}>Service ({{item.Service}})</a></li>
<li><strong>Instance</strong></li>
</ol>
{{/block-slot}}
{{#block-slot 'header'}}
<h1>
{{ item.ID }}
{{#with (service/external-source item) as |externalSource| }}
{{#with (css-var (concat '--' externalSource '-color-svg') 'none') as |bg| }}
{{#if (not-eq bg 'none') }}
<span data-test-external-source="{{externalSource}}" style={{{ concat 'background-image:' bg }}} data-tooltip="Registered via {{externalSource}}">Registered via {{externalSource}}</span>
{{/if}}
{{/with}}
{{/with}}
</h1>
<dl>
<dt>Service Name</dt>
<dd><a href="{{href-to 'dc.services.show' item.Service}}">{{item.Service}}</a></dd>
</dl>
<dl>
<dt>Node Name</dt>
<dd><a href="{{href-to 'dc.nodes.show' item.Node.Node}}">{{item.Node.Node}}</a></dd>
</dl>
{{#if proxy}}
<dl>
<dt>Sidecar Proxy</dt>
<dd><a href="{{href-to 'dc.services.instance' proxy.ServiceName proxy.ServiceID}}">{{proxy.ServiceID}}</a></dd>
</dl>
{{/if}}
{{#if (eq item.Kind 'connect-proxy')}}
<dl>
<dt>Dest. Service Instance</dt>
<dd><a href="{{href-to 'dc.services.instance' item.Proxy.DestinationServiceName item.Proxy.DestinationServiceID}}">{{item.Proxy.DestinationServiceID}}</a></dd>
</dl>
<dl>
<dt>Local Service Address</dt>
<dd>{{item.Proxy.LocalServiceAddress}}:{{item.Proxy.LocalServicePort}}</dd>
</dl>
{{/if}}
{{/block-slot}}
{{#block-slot 'content'}}
{{tab-nav
items=(compact
(array
'Service Checks'
'Node Checks'
(if (eq item.Kind 'connect-proxy') 'Upstreams' '')
'Tags'
)
)
selected=selectedTab
}}
{{#each
(compact
(array
(hash id=(slugify 'Service Checks') partial='dc/services/servicechecks')
(hash id=(slugify 'Node Checks') partial='dc/services/nodechecks')
(if (eq item.Kind 'connect-proxy') (hash id=(slugify 'Upstreams') partial='dc/services/upstreams') '')
(hash id=(slugify 'Tags') partial='dc/services/tags')
)
) as |panel|
}}
{{#tab-section id=panel.id selected=(eq (if selectedTab selectedTab '') panel.id) onchange=(action "change")}}
{{partial panel.partial}}
{{/tab-section}}
{{/each}}
{{/block-slot}}
{{/app-view}}

View File

@ -15,76 +15,29 @@
{{/with}}
{{/with}}
</h1>
{{/block-slot}}
{{#block-slot 'toolbar'}}
{{#if (gt items.length 0) }}
{{catalog-filter searchable=(array searchableHealthy searchableUnhealthy) filters=healthFilters search=s status=filters.status onchange=(action 'filter')}}
{{/if}}
<label for="toolbar-toggle"></label>
{{tab-nav
items=(compact
(array
'Instances'
'Tags'
)
)
selected=selectedTab
}}
{{/block-slot}}
{{#block-slot 'content'}}
{{#if (gt item.Tags.length 0)}}
<dl>
<dt>Tags</dt>
<dd data-test-tags>
{{#each item.Tags as |item|}}
<span>{{item}}</span>
{{/each}}
</dd>
</dl>
{{/if}}
{{#if (gt unhealthy.length 0) }}
<div data-test-unhealthy class="unhealthy">
<h2>Unhealthy Nodes</h2>
<div>
<ul>
{{#changeable-set dispatcher=searchableUnhealthy}}
{{#block-slot 'set' as |unhealthy|}}
{{#each unhealthy as |item|}}
{{healthchecked-resource
tagName='li'
data-test-node=item.Node.Node
href=(href-to 'dc.nodes.show' item.Node.Node)
name=item.Node.Node
service=item.Service.ID
address=(concat (default item.Service.Address item.Node.Address) ':' item.Service.Port)
checks=item.Checks
{{#each
(compact
(array
(hash id=(slugify 'Instances') partial='dc/services/instances')
(hash id=(slugify 'Tags') partial='dc/services/tags')
)
) as |panel|
}}
{{#tab-section id=panel.id selected=(eq (if selectedTab selectedTab '') panel.id) onchange=(action "change")}}
{{partial panel.partial}}
{{/tab-section}}
{{/each}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no unhealthy nodes for that search.
</p>
{{/block-slot}}
{{/changeable-set}}
</ul>
</div>
</div>
{{/if}}
{{#if (gt healthy.length 0) }}
<div data-test-healthy class="healthy">
<h2>Healthy Nodes</h2>
{{#changeable-set dispatcher=searchableHealthy}}
{{#block-slot 'set' as |healthy|}}
{{#list-collection cellHeight=113 items=healthy as |item index|}}
{{healthchecked-resource
href=(href-to 'dc.nodes.show' item.Node.Node)
data-test-node=item.Node.Node
name=item.Node.Node
service=item.Service.ID
address=(concat (default item.Service.Address item.Node.Address) ':' item.Service.Port)
checks=item.Checks
status=item.Checks.[0].Status
}}
{{/list-collection}}
{{/block-slot}}
{{#block-slot 'empty'}}
<p>
There are no healthy nodes for that search.
</p>
{{/block-slot}}
{{/changeable-set}}
</div>
{{/if}}
{{/block-slot}}
{{/app-view}}

View File

@ -1,4 +1,4 @@
import { get } from '@ember/object';
import { get, computed } from '@ember/object';
/**
* Converts a conventional non-pure Ember `computed` function into a pure one
@ -8,20 +8,18 @@ import { get } from '@ember/object';
* @param {function} filter - Optional string filter function to pre-process the names of computed properties
* @returns {function} - A pure `computed` function
*/
export default function(computed, filter) {
const _success = function(value) {
return value;
};
const purify = function(computed, filter = args => args) {
return function() {
let args = [...arguments];
let success = function(value) {
return value;
};
let success = _success;
// pop the user function off the end
if (typeof args[args.length - 1] === 'function') {
success = args.pop();
}
if (typeof filter === 'function') {
args = filter(args);
}
// this is the 'conventional' `computed`
const cb = function(name) {
return success.apply(
@ -39,4 +37,6 @@ export default function(computed, filter) {
// concat/push the user function back on
return computed(...args.concat([cb]));
};
}
};
export const subscribe = purify(computed);
export default purify;

View File

@ -123,31 +123,6 @@ Feature: components / catalog-filter
| Model | Page | Url |
| service | node | /dc-1/nodes/node-0 |
-------------------------------------------------
Scenario: Filtering [Model] in [Page]
Given 1 datacenter model with the value "dc1"
And 2 [Model] models from yaml
---
- ID: node-0
---
When I visit the [Page] page for yaml
---
dc: dc1
service: service-0
---
Then I fill in with yaml
---
s: service-0-with-id
---
And I see 1 [Model] model
Then I see id on the unhealthy like yaml
---
- service-0-with-id
---
Where:
-------------------------------------------------
| Model | Page | Url |
| nodes | service | /dc-1/services/service-0 |
-------------------------------------------------
Scenario:
Given 1 datacenter model with the value "dc-1"
And 3 service models from yaml

View File

@ -52,7 +52,7 @@ Feature: dc / services / show: Show Service
Then I see the text "Tag1" in "[data-test-tags] span:nth-child(1)"
Then I see the text "Tag2" in "[data-test-tags] span:nth-child(2)"
Then I see the text "Tag3" in "[data-test-tags] span:nth-child(3)"
Scenario: Given various services the various ports on their nodes are displayed
Scenario: Given various services the various nodes on their instances are displayed
Given 1 datacenter model with the value "dc1"
And 3 node models
And 1 service model from yaml
@ -83,21 +83,9 @@ Feature: dc / services / show: Show Service
dc: dc1
service: service-0
---
Then I see address on the healthy like yaml
Then I see address on the instances like yaml
---
- "1.1.1.1:8080"
---
Then I see address on the unhealthy like yaml
---
- "2.2.2.2:8000"
- "3.3.3.3:8888"
---
Then I see id on the healthy like yaml
---
- "passing-service-8080"
---
Then I see id on the unhealthy like yaml
---
- "service-8000"
- "service-8888"
---

View File

@ -0,0 +1,22 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('healthcheck-info', 'Integration | Component | healthcheck info', {
integration: true,
});
test('it renders', function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
this.render(hbs`{{healthcheck-info}}`);
assert.equal(this.$('dl').length, 1);
// Template block usage:
this.render(hbs`
{{#healthcheck-info}}
{{/healthcheck-info}}
`);
assert.equal(this.$('dl').length, 1);
});

View File

@ -0,0 +1,23 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('healthcheck-list', 'Integration | Component | healthcheck list', {
integration: true,
});
test('it renders', function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
this.render(hbs`{{healthcheck-list}}`);
assert.equal(this.$('ul').length, 1);
// Template block usage:
this.render(hbs`
{{#healthcheck-list}}
{{/healthcheck-list}}
`);
assert.equal(this.$('ul').length, 1);
});

View File

@ -0,0 +1,34 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('healthcheck-output', 'Integration | Component | healthcheck output', {
integration: true,
});
test('it renders', function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
this.render(hbs`{{healthcheck-output}}`);
assert.notEqual(
this.$()
.text()
.trim()
.indexOf('Output'),
-1
);
// Template block usage:
this.render(hbs`
{{#healthcheck-output}}{{/healthcheck-output}}
`);
assert.notEqual(
this.$()
.text()
.trim()
.indexOf('Output'),
-1
);
});

View File

@ -10,25 +10,11 @@ test('it renders', function(assert) {
// Handle any actions with this.on('myAction', function(val) { ... });
this.render(hbs`{{healthcheck-status}}`);
assert.notEqual(
this.$()
.text()
.trim()
.indexOf('Output'),
-1
);
assert.equal(this.$('dt').length, 1);
// Template block usage:
this.render(hbs`
{{#healthcheck-status}}{{/healthcheck-status}}
`);
assert.notEqual(
this.$()
.text()
.trim()
.indexOf('Output'),
-1
);
assert.equal(this.$('dt').length, 1);
});

View File

@ -0,0 +1,33 @@
import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
moduleForComponent('tag-list', 'Integration | Component | tag list', {
integration: true,
});
test('it renders', function(assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
this.render(hbs`{{tag-list}}`);
assert.equal(
this.$()
.text()
.trim(),
''
);
// Template block usage:
this.render(hbs`
{{#tag-list}}
{{/tag-list}}
`);
assert.equal(
this.$()
.text()
.trim(),
''
);
});

View File

@ -2,18 +2,8 @@ export default function(visitable, attribute, collection, text, filter) {
return {
visit: visitable('/:dc/services/:service'),
externalSource: attribute('data-test-external-source', 'h1 span'),
nodes: collection('[data-test-node]', {
name: attribute('data-test-node'),
}),
healthy: collection('[data-test-healthy] [data-test-node]', {
name: attribute('data-test-node'),
address: text('header strong'),
id: text('header em'),
}),
unhealthy: collection('[data-test-unhealthy] [data-test-node]', {
name: attribute('data-test-node'),
address: text('header strong'),
id: text('header em'),
instances: collection('#instances [data-test-tabular-row]', {
address: text('[data-test-address]'),
}),
filter: filter,
};

View File

@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Adapter | proxy', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let adapter = this.owner.lookup('adapter:proxy');
assert.ok(adapter);
});
});

View File

@ -0,0 +1,12 @@
import { moduleFor, test } from 'ember-qunit';
moduleFor('controller:dc/services/instance', 'Unit | Controller | dc/services/instance', {
// Specify the other units that are required for this test.
// needs: ['controller:foo']
});
// Replace this with your real tests.
test('it exists', function(assert) {
let controller = this.subject();
assert.ok(controller);
});

View File

@ -0,0 +1,14 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { run } from '@ember/runloop';
module('Unit | Model | proxy', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let model = run(() => store.createRecord('proxy', {}));
assert.ok(model);
});
});

View File

@ -0,0 +1,11 @@
import { moduleFor, test } from 'ember-qunit';
moduleFor('route:dc/services/instance', 'Unit | Route | dc/services/instance', {
// Specify the other units that are required for this test.
needs: ['service:repository/service', 'service:repository/proxy'],
});
test('it exists', function(assert) {
let route = this.subject();
assert.ok(route);
});

View File

@ -0,0 +1,24 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { run } from '@ember/runloop';
module('Unit | Serializer | proxy', function(hooks) {
setupTest(hooks);
// Replace this with your real tests.
test('it exists', function(assert) {
let store = this.owner.lookup('service:store');
let serializer = store.serializerFor('proxy');
assert.ok(serializer);
});
test('it serializes records', function(assert) {
let store = this.owner.lookup('service:store');
let record = run(() => store.createRecord('proxy', {}));
let serializedRecord = record.serialize();
assert.ok(serializedRecord);
});
});