diff --git a/ui/app/components/list-accordion.js b/ui/app/components/list-accordion.js
new file mode 100644
index 000000000..9e004645d
--- /dev/null
+++ b/ui/app/components/list-accordion.js
@@ -0,0 +1,30 @@
+import Component from '@ember/component';
+import { computed, get } from '@ember/object';
+
+export default Component.extend({
+ classNames: ['accordion'],
+
+ key: 'id',
+ source: computed(() => []),
+
+ decoratedSource: computed('source.[]', function() {
+ const stateCache = this.get('stateCache');
+ const key = this.get('key');
+ const deepKey = `item.${key}`;
+
+ const decoratedSource = this.get('source').map(item => {
+ const cacheItem = stateCache.findBy(deepKey, get(item, key));
+ return {
+ item,
+ isOpen: cacheItem ? !!cacheItem.isOpen : false,
+ };
+ });
+
+ this.set('stateCache', decoratedSource);
+ return decoratedSource;
+ }),
+
+ // When source updates come in, the state cache is used to preserve
+ // open/close state.
+ stateCache: computed(() => []),
+});
diff --git a/ui/app/components/list-accordion/accordion-body.js b/ui/app/components/list-accordion/accordion-body.js
new file mode 100644
index 000000000..32397fce6
--- /dev/null
+++ b/ui/app/components/list-accordion/accordion-body.js
@@ -0,0 +1,6 @@
+import Component from '@ember/component';
+
+export default Component.extend({
+ tagName: '',
+ isOpen: false,
+});
diff --git a/ui/app/components/list-accordion/accordion-head.js b/ui/app/components/list-accordion/accordion-head.js
new file mode 100644
index 000000000..9cff6e92b
--- /dev/null
+++ b/ui/app/components/list-accordion/accordion-head.js
@@ -0,0 +1,16 @@
+import Component from '@ember/component';
+
+export default Component.extend({
+ classNames: ['accordion-head'],
+ classNameBindings: ['isOpen::is-light', 'isExpandable::is-inactive'],
+
+ 'data-test-accordion-head': true,
+
+ buttonLabel: 'toggle',
+ isOpen: false,
+ isExpandable: true,
+ item: null,
+
+ onClose() {},
+ onOpen() {},
+});
diff --git a/ui/app/controllers/clients/client.js b/ui/app/controllers/clients/client.js
index 008169f5e..26dfc9b64 100644
--- a/ui/app/controllers/clients/client.js
+++ b/ui/app/controllers/clients/client.js
@@ -24,6 +24,16 @@ export default Controller.extend(Sortable, Searchable, {
listToSearch: alias('listSorted'),
sortedAllocations: alias('listSearched'),
+ sortedEvents: computed('model.events.@each.time', function() {
+ return this.get('model.events')
+ .sortBy('time')
+ .reverse();
+ }),
+
+ sortedDrivers: computed('model.drivers.@each.name', function() {
+ return this.get('model.drivers').sortBy('name');
+ }),
+
actions: {
gotoAllocation(allocation) {
this.transitionToRoute('allocations.allocation', allocation);
diff --git a/ui/app/mixins/with-component-visibility-detection.js b/ui/app/mixins/with-component-visibility-detection.js
index 66d1097ee..605937834 100644
--- a/ui/app/mixins/with-component-visibility-detection.js
+++ b/ui/app/mixins/with-component-visibility-detection.js
@@ -1,3 +1,4 @@
+import Ember from 'ember';
import Mixin from '@ember/object/mixin';
import { assert } from '@ember/debug';
@@ -7,11 +8,15 @@ export default Mixin.create({
},
setupDocumentVisibility: function() {
- this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
- document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
+ if (!Ember.testing) {
+ this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
+ document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
+ }
}.on('init'),
removeDocumentVisibility: function() {
- document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
+ if (!Ember.testing) {
+ document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
+ }
}.on('willDestroy'),
});
diff --git a/ui/app/mixins/with-route-visibility-detection.js b/ui/app/mixins/with-route-visibility-detection.js
index 1df6e5f45..57e8300e3 100644
--- a/ui/app/mixins/with-route-visibility-detection.js
+++ b/ui/app/mixins/with-route-visibility-detection.js
@@ -1,3 +1,4 @@
+import Ember from 'ember';
import Mixin from '@ember/object/mixin';
import { assert } from '@ember/debug';
@@ -7,11 +8,15 @@ export default Mixin.create({
},
setupDocumentVisibility: function() {
- this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
- document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
+ if (!Ember.testing) {
+ this.set('_visibilityHandler', this.get('visibilityHandler').bind(this));
+ document.addEventListener('visibilitychange', this.get('_visibilityHandler'));
+ }
}.on('activate'),
removeDocumentVisibility: function() {
- document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
+ if (!Ember.testing) {
+ document.removeEventListener('visibilitychange', this.get('_visibilityHandler'));
+ }
}.on('deactivate'),
});
diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js
index 048e31e55..f4dbad07d 100644
--- a/ui/app/models/allocation.js
+++ b/ui/app/models/allocation.js
@@ -4,6 +4,7 @@ import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo } from 'ember-data/relationships';
import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
+import intersection from 'npm:lodash.intersection';
import shortUUIDProperty from '../utils/properties/short-uuid';
import AllocationStats from '../utils/classes/allocation-stats';
@@ -61,6 +62,17 @@ export default Model.extend({
return taskGroups && taskGroups.findBy('name', this.get('taskGroupName'));
}),
+ unhealthyDrivers: computed('taskGroup.drivers.[]', 'node.unhealthyDriverNames.[]', function() {
+ const taskGroupUnhealthyDrivers = this.get('taskGroup.drivers');
+ const nodeUnhealthyDrivers = this.get('node.unhealthyDriverNames');
+
+ if (taskGroupUnhealthyDrivers && nodeUnhealthyDrivers) {
+ return intersection(taskGroupUnhealthyDrivers, nodeUnhealthyDrivers);
+ }
+
+ return [];
+ }),
+
fetchStats() {
return this.get('token')
.authorizedRequest(`/v1/client/allocation/${this.get('id')}/stats`)
diff --git a/ui/app/models/job.js b/ui/app/models/job.js
index b8ba9670f..621b869ca 100644
--- a/ui/app/models/job.js
+++ b/ui/app/models/job.js
@@ -116,6 +116,28 @@ export default Model.extend({
evaluations: hasMany('evaluations'),
namespace: belongsTo('namespace'),
+ drivers: computed('taskGroups.@each.drivers', function() {
+ return this.get('taskGroups')
+ .mapBy('drivers')
+ .reduce((all, drivers) => {
+ all.push(...drivers);
+ return all;
+ }, [])
+ .uniq();
+ }),
+
+ // Getting all unhealthy drivers for a job can be incredibly expensive if the job
+ // has many allocations. This can lead to making an API request for many nodes.
+ unhealthyDrivers: computed('allocations.@each.unhealthyDrivers.[]', function() {
+ return this.get('allocations')
+ .mapBy('unhealthyDrivers')
+ .reduce((all, drivers) => {
+ all.push(...drivers);
+ return all;
+ }, [])
+ .uniq();
+ }),
+
hasBlockedEvaluation: computed('evaluations.@each.isBlocked', function() {
return this.get('evaluations')
.toArray()
diff --git a/ui/app/models/node-driver.js b/ui/app/models/node-driver.js
new file mode 100644
index 000000000..371c5cc49
--- /dev/null
+++ b/ui/app/models/node-driver.js
@@ -0,0 +1,26 @@
+import Fragment from 'ember-data-model-fragments/fragment';
+import { computed, get } from '@ember/object';
+import attr from 'ember-data/attr';
+import { fragmentOwner } from 'ember-data-model-fragments/attributes';
+import { fragment } from 'ember-data-model-fragments/attributes';
+
+export default Fragment.extend({
+ node: fragmentOwner(),
+
+ attributes: fragment('node-attributes'),
+
+ attributesShort: computed('name', 'attributes.attributesStructured', function() {
+ const attributes = this.get('attributes.attributesStructured');
+ return get(attributes, `driver.${this.get('name')}`);
+ }),
+
+ name: attr('string'),
+ detected: attr('boolean', { defaultValue: false }),
+ healthy: attr('boolean', { defaultValue: false }),
+ healthDescription: attr('string'),
+ updateTime: attr('date'),
+
+ healthClass: computed('healthy', function() {
+ return this.get('healthy') ? 'running' : 'failed';
+ }),
+});
diff --git a/ui/app/models/node-event.js b/ui/app/models/node-event.js
new file mode 100644
index 000000000..8fcf3cfad
--- /dev/null
+++ b/ui/app/models/node-event.js
@@ -0,0 +1,15 @@
+import { alias } from '@ember/object/computed';
+import Fragment from 'ember-data-model-fragments/fragment';
+import attr from 'ember-data/attr';
+import { fragmentOwner } from 'ember-data-model-fragments/attributes';
+
+export default Fragment.extend({
+ node: fragmentOwner(),
+
+ message: attr('string'),
+ subsystem: attr('string'),
+ details: attr(),
+ time: attr('date'),
+
+ driver: alias('details.driver'),
+});
diff --git a/ui/app/models/node.js b/ui/app/models/node.js
index 4b5f34a44..4dbb4fec6 100644
--- a/ui/app/models/node.js
+++ b/ui/app/models/node.js
@@ -2,7 +2,7 @@ import { computed } from '@ember/object';
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { hasMany } from 'ember-data/relationships';
-import { fragment } from 'ember-data-model-fragments/attributes';
+import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
import shortUUIDProperty from '../utils/properties/short-uuid';
import ipParts from '../utils/ip-parts';
@@ -37,4 +37,19 @@ export default Model.extend({
}),
allocations: hasMany('allocations', { inverse: 'node' }),
+
+ drivers: fragmentArray('node-driver'),
+ events: fragmentArray('node-event'),
+
+ detectedDrivers: computed('drivers.@each.detected', function() {
+ return this.get('drivers').filterBy('detected');
+ }),
+
+ unhealthyDrivers: computed('detectedDrivers.@each.healthy', function() {
+ return this.get('detectedDrivers').filterBy('healthy', false);
+ }),
+
+ unhealthyDriverNames: computed('unhealthyDrivers.@each.name', function() {
+ return this.get('unhealthyDrivers').mapBy('name');
+ }),
});
diff --git a/ui/app/models/task-group.js b/ui/app/models/task-group.js
index 9be65bdba..ab1a37bbe 100644
--- a/ui/app/models/task-group.js
+++ b/ui/app/models/task-group.js
@@ -14,6 +14,12 @@ export default Fragment.extend({
tasks: fragmentArray('task'),
+ drivers: computed('tasks.@each.driver', function() {
+ return this.get('tasks')
+ .mapBy('driver')
+ .uniq();
+ }),
+
allocations: computed('job.allocations.@each.taskGroup', function() {
return maybe(this.get('job.allocations')).filterBy('taskGroupName', this.get('name'));
}),
diff --git a/ui/app/models/task-state.js b/ui/app/models/task-state.js
index 217e87459..e0c8918c8 100644
--- a/ui/app/models/task-state.js
+++ b/ui/app/models/task-state.js
@@ -1,5 +1,6 @@
import { none } from '@ember/object/computed';
import { computed } from '@ember/object';
+import { alias } from '@ember/object/computed';
import Fragment from 'ember-data-model-fragments/fragment';
import attr from 'ember-data/attr';
import { fragment, fragmentOwner, fragmentArray } from 'ember-data-model-fragments/attributes';
@@ -19,6 +20,15 @@ export default Fragment.extend({
return tasks && tasks.findBy('name', this.get('name'));
}),
+ driver: alias('task.driver'),
+
+ // TaskState represents a task running on a node, so in addition to knowing the
+ // driver via the task, the health of the driver is also known via the node
+ driverStatus: computed('task.driver', 'allocation.node.drivers.[]', function() {
+ const nodeDrivers = this.get('allocation.node.drivers') || [];
+ return nodeDrivers.findBy('name', this.get('task.driver'));
+ }),
+
resources: fragment('resources'),
events: fragmentArray('task-event'),
diff --git a/ui/app/serializers/node-event.js b/ui/app/serializers/node-event.js
new file mode 100644
index 000000000..606c6dadd
--- /dev/null
+++ b/ui/app/serializers/node-event.js
@@ -0,0 +1,7 @@
+import ApplicationSerializer from './application';
+
+export default ApplicationSerializer.extend({
+ attrs: {
+ time: 'Timestamp',
+ },
+});
diff --git a/ui/app/serializers/node.js b/ui/app/serializers/node.js
index e0ecfc9a7..a8d3410e2 100644
--- a/ui/app/serializers/node.js
+++ b/ui/app/serializers/node.js
@@ -1,3 +1,5 @@
+import { get } from '@ember/object';
+import { assign } from '@ember/polyfills';
import { inject as service } from '@ember/service';
import ApplicationSerializer from './application';
@@ -9,11 +11,10 @@ export default ApplicationSerializer.extend({
},
normalize(modelClass, hash) {
- // Proxy local agent to the same proxy express server Ember is using
- // to avoid CORS
- if (this.get('config.isDev') && hash.HTTPAddr === '127.0.0.1:4646') {
- hash.HTTPAddr = '127.0.0.1:4200';
- }
+ // Transform the map-based Drivers object into an array-based NodeDriver fragment list
+ hash.Drivers = Object.keys(get(hash, 'Drivers') || {}).map(key => {
+ return assign({}, get(hash, `Drivers.${key}`), { Name: key });
+ });
return this._super(modelClass, hash);
},
diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss
index 49b513806..83efbb676 100644
--- a/ui/app/styles/components.scss
+++ b/ui/app/styles/components.scss
@@ -1,3 +1,4 @@
+@import './components/accordion';
@import './components/badge';
@import './components/boxed-section';
@import './components/cli-window';
diff --git a/ui/app/styles/components/accordion.scss b/ui/app/styles/components/accordion.scss
new file mode 100644
index 000000000..17ca8bcc5
--- /dev/null
+++ b/ui/app/styles/components/accordion.scss
@@ -0,0 +1,42 @@
+.accordion {
+ .accordion-head,
+ .accordion-body {
+ border: 1px solid $grey-blue;
+ border-bottom: none;
+ padding: 0.75em 1.5em;
+
+ &:first-child {
+ border-top-left-radius: $radius;
+ border-top-right-radius: $radius;
+ }
+
+ &:last-child {
+ border-bottom: 1px solid $grey-blue;
+ border-bottom-left-radius: $radius;
+ border-bottom-right-radius: $radius;
+ }
+ }
+
+ .accordion-head {
+ display: flex;
+ background: $white-ter;
+ flex: 1;
+
+ &.is-light {
+ background: $white;
+ }
+
+ &.is-inactive {
+ color: $grey-light;
+ }
+
+ .accordion-head-content {
+ width: 100%;
+ }
+
+ .accordion-toggle {
+ flex-basis: 0;
+ white-space: nowrap;
+ }
+ }
+}
diff --git a/ui/app/styles/components/badge.scss b/ui/app/styles/components/badge.scss
index e5fc8b173..60f4f2c74 100644
--- a/ui/app/styles/components/badge.scss
+++ b/ui/app/styles/components/badge.scss
@@ -33,4 +33,13 @@
&.is-faded {
color: rgba($text, 0.8);
}
+
+ &.is-small {
+ padding: 0.15em 0.5em;
+ }
+
+ &.is-secondary {
+ color: darken($grey-blue, 30%);
+ background: lighten($grey-blue, 10%);
+ }
}
diff --git a/ui/app/styles/core/table.scss b/ui/app/styles/core/table.scss
index 97ff41845..63822653b 100644
--- a/ui/app/styles/core/table.scss
+++ b/ui/app/styles/core/table.scss
@@ -86,6 +86,11 @@
&.is-narrow {
padding: 1.25em 0 1.25em 0.5em;
+
+ & + th,
+ & + td {
+ padding-left: 0.5em;
+ }
}
// Only use px modifiers when text needs to be truncated.
diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs
index 1aa22ab0a..c2e0a60ce 100644
--- a/ui/app/templates/allocations/allocation/index.hbs
+++ b/ui/app/templates/allocations/allocation/index.hbs
@@ -40,6 +40,7 @@
sortDescending=sortDescending
class="is-striped" as |t|}}
{{#t.head}}
+
|
{{#t.sort-by prop="name"}}Name{{/t.sort-by}}
{{#t.sort-by prop="state"}}State{{/t.sort-by}}
Last Event |
@@ -48,6 +49,13 @@
{{/t.head}}
{{#t.body as |row|}}
+ |
+ {{#if (not row.model.driverStatus.healthy)}}
+
+ {{x-icon "warning" class="is-warning"}}
+
+ {{/if}}
+ |
{{#link-to "allocations.allocation.task" row.model.allocation row.model}}
{{row.model.name}}
diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs
index b9ec625aa..f5ad36c9f 100644
--- a/ui/app/templates/clients/client.hbs
+++ b/ui/app/templates/clients/client.hbs
@@ -17,9 +17,27 @@
Client Details
- Status {{model.status}}
- Address {{model.httpAddr}}
- Datacenter {{model.datacenter}}
+
+ Status
+ {{model.status}}
+
+
+ Address
+ {{model.httpAddr}}
+
+
+ Datacenter
+ {{model.datacenter}}
+
+
+ Drivers
+ {{#if model.unhealthyDrivers.length}}
+ {{x-icon "warning" class="is-text is-warning"}}
+ {{model.unhealthyDrivers.length}} of {{model.detectedDrivers.length}} {{pluralize "driver" model.detectedDrivers.length}} unhealthy
+ {{else}}
+ All healthy
+ {{/if}}
+
@@ -75,6 +93,95 @@
+
+
+ Client Events
+
+
+ {{#list-table source=sortedEvents class="is-striped" as |t|}}
+ {{#t.head}}
+ Time |
+ Subsystem |
+ Message |
+ {{/t.head}}
+ {{#t.body as |row|}}
+
+ | {{moment-format row.model.time "MM/DD/YY HH:mm:ss"}} |
+ {{row.model.subsystem}} |
+
+ {{#if row.model.message}}
+ {{#if row.model.driver}}
+ {{row.model.driver}}
+ {{/if}}
+ {{row.model.message}}
+ {{else}}
+ No message
+ {{/if}}
+ |
+
+ {{/t.body}}
+ {{/list-table}}
+
+
+
+
+
+ Driver Status
+
+
+ {{#list-accordion source=sortedDrivers key="name" as |a|}}
+ {{#a.head buttonLabel="details" isExpandable=a.item.detected}}
+
+
+ {{a.item.name}}
+
+
+ {{#if a.item.detected}}
+
+
+ {{if a.item.healthy "Healthy" "Unhealthy"}}
+
+ {{/if}}
+
+
+
+ Detected
+ {{if a.item.detected "Yes" "No"}}
+
+
+
+ Last Updated
+ {{moment-from-now a.item.updateTime interval=1000}}
+
+
+
+
+ {{/a.head}}
+ {{#a.body}}
+ {{a.item.healthDescription}}
+
+
+ {{capitalize a.item.name}} Attributes
+
+ {{#if a.item.attributes.attributesStructured}}
+
+ {{attributes-table
+ attributes=a.item.attributesShort
+ class="attributes-table"}}
+
+ {{else}}
+
+
+ No Driver Attributes
+
+
+ {{/if}}
+
+ {{/a.body}}
+ {{/list-accordion}}
+
+
+
Attributes
diff --git a/ui/app/templates/clients/index.hbs b/ui/app/templates/clients/index.hbs
index 1b43c78c3..888c05634 100644
--- a/ui/app/templates/clients/index.hbs
+++ b/ui/app/templates/clients/index.hbs
@@ -23,6 +23,7 @@
sortDescending=sortDescending
class="with-foot" as |t|}}
{{#t.head}}
+ |
{{#t.sort-by prop="id"}}ID{{/t.sort-by}}
{{#t.sort-by class="is-200px is-truncatable" prop="name"}}Name{{/t.sort-by}}
{{#t.sort-by prop="status"}}Status{{/t.sort-by}}
diff --git a/ui/app/templates/components/allocation-row.hbs b/ui/app/templates/components/allocation-row.hbs
index b2f7e1103..1da565ff5 100644
--- a/ui/app/templates/components/allocation-row.hbs
+++ b/ui/app/templates/components/allocation-row.hbs
@@ -1,6 +1,11 @@
+ {{#if allocation.unhealthyDrivers.length}}
+
+ {{x-icon "warning" class="is-warning"}}
+
+ {{/if}}
{{#if allocation.nextAllocation}}
-
+
{{x-icon "history" class="is-faded"}}
{{/if}}
diff --git a/ui/app/templates/components/client-node-row.hbs b/ui/app/templates/components/client-node-row.hbs
index 184a00e2b..9a946006d 100644
--- a/ui/app/templates/components/client-node-row.hbs
+++ b/ui/app/templates/components/client-node-row.hbs
@@ -1,3 +1,10 @@
+ |
+ {{#if node.unhealthyDrivers.length}}
+
+ {{x-icon "warning" class="is-warning"}}
+
+ {{/if}}
+ |
{{#link-to "clients.client" node.id class="is-primary"}}{{node.shortId}}{{/link-to}} |
{{node.name}} |
{{node.status}} |
diff --git a/ui/app/templates/components/list-accordion.hbs b/ui/app/templates/components/list-accordion.hbs
new file mode 100644
index 000000000..67678cef2
--- /dev/null
+++ b/ui/app/templates/components/list-accordion.hbs
@@ -0,0 +1,10 @@
+{{#each decoratedSource as |item|}}
+ {{yield (hash
+ head=(component "list-accordion/accordion-head"
+ isOpen=item.isOpen
+ onOpen=(action (mut item.isOpen) true)
+ onClose=(action (mut item.isOpen) false))
+ body=(component "list-accordion/accordion-body" isOpen=item.isOpen)
+ item=item.item
+ )}}
+{{/each}}
diff --git a/ui/app/templates/components/list-accordion/accordion-body.hbs b/ui/app/templates/components/list-accordion/accordion-body.hbs
new file mode 100644
index 000000000..35512b2b7
--- /dev/null
+++ b/ui/app/templates/components/list-accordion/accordion-body.hbs
@@ -0,0 +1,5 @@
+{{#if isOpen}}
+
+ {{yield}}
+
+{{/if}}
diff --git a/ui/app/templates/components/list-accordion/accordion-head.hbs b/ui/app/templates/components/list-accordion/accordion-head.hbs
new file mode 100644
index 000000000..5d5cab568
--- /dev/null
+++ b/ui/app/templates/components/list-accordion/accordion-head.hbs
@@ -0,0 +1,9 @@
+
+ {{yield}}
+
+
diff --git a/ui/mirage/factories/node-event.js b/ui/mirage/factories/node-event.js
new file mode 100644
index 000000000..a00435d56
--- /dev/null
+++ b/ui/mirage/factories/node-event.js
@@ -0,0 +1,12 @@
+import { Factory, faker } from 'ember-cli-mirage';
+import { provide } from '../utils';
+
+const REF_TIME = new Date();
+const STATES = provide(10, faker.system.fileExt.bind(faker.system));
+
+export default Factory.extend({
+ subsystem: faker.list.random(...STATES),
+ message: () => faker.lorem.sentence(),
+ time: () => faker.date.past(2 / 365, REF_TIME),
+ details: null,
+});
diff --git a/ui/mirage/factories/node.js b/ui/mirage/factories/node.js
index ba4bd569e..3950f3692 100644
--- a/ui/mirage/factories/node.js
+++ b/ui/mirage/factories/node.js
@@ -4,6 +4,7 @@ import { DATACENTERS, HOSTS } from '../common';
const UUIDS = provide(100, faker.random.uuid.bind(faker.random));
const NODE_STATUSES = ['initializing', 'ready', 'down'];
+const REF_DATE = new Date();
export default Factory.extend({
id: i => (i / 100 >= 1 ? `${UUIDS[i]}-${i}` : UUIDS[i]),
@@ -28,6 +29,8 @@ export default Factory.extend({
},
}),
+ drivers: makeDrivers,
+
attributes() {
// TODO add variability to these
return {
@@ -72,5 +75,42 @@ export default Factory.extend({
server.create('client-stats', {
id: node.httpAddr,
});
+
+ const events = server.createList('node-event', faker.random.number({ min: 1, max: 10 }), {
+ nodeId: node.id,
+ });
+
+ node.update({
+ eventIds: events.mapBy('id'),
+ });
},
});
+
+function makeDrivers() {
+ const generate = name => {
+ const detected = Math.random() > 0.3;
+ const healthy = detected && Math.random() > 0.3;
+ const attributes = {
+ [`driver.${name}.version`]: '1.0.0',
+ [`driver.${name}.status`]: 'awesome',
+ [`driver.${name}.more.details`]: 'yeah',
+ [`driver.${name}.more.again`]: 'we got that',
+ };
+ return {
+ Detected: detected,
+ Healthy: healthy,
+ HealthDescription: healthy ? 'Driver is healthy' : 'Uh oh',
+ UpdateTime: faker.date.past(5 / 365, REF_DATE),
+ Attributes: Math.random() > 0.3 && detected ? attributes : null,
+ };
+ };
+
+ return {
+ docker: generate('docker'),
+ rkt: generate('rkt'),
+ qemu: generate('qemu'),
+ exec: generate('exec'),
+ raw_exec: generate('raw_exec'),
+ java: generate('java'),
+ };
+}
diff --git a/ui/mirage/factories/task-event.js b/ui/mirage/factories/task-event.js
index ab161cdb2..72397110c 100644
--- a/ui/mirage/factories/task-event.js
+++ b/ui/mirage/factories/task-event.js
@@ -1,4 +1,4 @@
-import { Factory, faker, trait } from 'ember-cli-mirage';
+import { Factory, faker } from 'ember-cli-mirage';
import { provide } from '../utils';
const REF_TIME = new Date();
diff --git a/ui/mirage/factories/task.js b/ui/mirage/factories/task.js
index 47afe3bbd..49733e938 100644
--- a/ui/mirage/factories/task.js
+++ b/ui/mirage/factories/task.js
@@ -1,6 +1,8 @@
import { Factory, faker } from 'ember-cli-mirage';
import { generateResources } from '../common';
+const DRIVERS = ['docker', 'java', 'rkt', 'qemu', 'exec', 'raw_exec'];
+
export default Factory.extend({
// Hidden property used to compute the Summary hash
groupNames: [],
@@ -8,6 +10,7 @@ export default Factory.extend({
JobID: '',
name: id => `task-${faker.hacker.noun()}-${id}`,
+ driver: faker.list.random(...DRIVERS),
Resources: generateResources,
});
diff --git a/ui/mirage/models/node.js b/ui/mirage/models/node.js
new file mode 100644
index 000000000..e42622483
--- /dev/null
+++ b/ui/mirage/models/node.js
@@ -0,0 +1,5 @@
+import { Model, hasMany } from 'ember-cli-mirage';
+
+export default Model.extend({
+ events: hasMany('node-event'),
+});
diff --git a/ui/mirage/serializers/node.js b/ui/mirage/serializers/node.js
new file mode 100644
index 000000000..506c19038
--- /dev/null
+++ b/ui/mirage/serializers/node.js
@@ -0,0 +1,6 @@
+import ApplicationSerializer from './application';
+
+export default ApplicationSerializer.extend({
+ embed: true,
+ include: ['events'],
+});
diff --git a/ui/package.json b/ui/package.json
index 649c495a3..566b798c9 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -72,6 +72,7 @@
"json-formatter-js": "^2.2.0",
"lint-staged": "^6.0.0",
"loader.js": "^4.2.3",
+ "lodash.intersection": "^4.4.0",
"prettier": "^1.4.4",
"query-string": "^5.0.0"
},
diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js
index 6218946b4..bfb8821fd 100644
--- a/ui/tests/acceptance/allocation-detail-test.js
+++ b/ui/tests/acceptance/allocation-detail-test.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { assign } from '@ember/polyfills';
import { click, findAll, currentURL, find, visit, waitFor } from 'ember-native-dom-helpers';
import { test } from 'qunit';
import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance';
@@ -13,9 +14,24 @@ moduleForAcceptance('Acceptance | allocation detail', {
server.create('agent');
node = server.create('node');
- job = server.create('job', { groupCount: 0 });
+ job = server.create('job', { groupCount: 0, createAllocations: false });
allocation = server.create('allocation', 'withTaskWithPorts');
+ // Make sure the node has an unhealthy driver
+ node.update({
+ driver: assign(node.drivers, {
+ docker: {
+ detected: true,
+ healthy: false,
+ },
+ }),
+ });
+
+ // Make sure a task for the allocation depends on the unhealthy driver
+ server.schema.tasks.first().update({
+ driver: 'docker',
+ });
+
visit(`/allocations/${allocation.id}`);
},
});
@@ -121,6 +137,10 @@ test('each task row should list high-level information for the task', function(a
});
});
+test('tasks with an unhealthy driver have a warning icon', function(assert) {
+ assert.ok(find('[data-test-task-row] [data-test-icon="unhealthy-driver"]'), 'Warning is shown');
+});
+
test('when the allocation has not been rescheduled, the reschedule events section is not rendered', function(assert) {
assert.notOk(find('[data-test-reschedule-events]'), 'Reschedule Events section exists');
});
diff --git a/ui/tests/acceptance/client-detail-test.js b/ui/tests/acceptance/client-detail-test.js
index a57b89cf6..357779a56 100644
--- a/ui/tests/acceptance/client-detail-test.js
+++ b/ui/tests/acceptance/client-detail-test.js
@@ -1,3 +1,4 @@
+import { assign } from '@ember/polyfills';
import $ from 'jquery';
import { click, find, findAll, currentURL, visit } from 'ember-native-dom-helpers';
import { test } from 'qunit';
@@ -24,12 +25,12 @@ test('/clients/:id should have a breadcrumb trail linking back to clients', func
andThen(() => {
assert.equal(
- find('[data-test-breadcrumb="clients"]').textContent,
+ find('[data-test-breadcrumb="clients"]').textContent.trim(),
'Clients',
'First breadcrumb says clients'
);
assert.equal(
- find('[data-test-breadcrumb="client"]').textContent,
+ find('[data-test-breadcrumb="client"]').textContent.trim(),
node.id.split('-')[0],
'Second breadcrumb says the node short id'
);
@@ -58,23 +59,26 @@ test('/clients/:id should list additional detail for the node below the title',
visit(`/clients/${node.id}`);
andThen(() => {
- assert.equal(
- findAll('.inline-definitions .pair')[0].textContent,
- `Status ${node.status}`,
+ assert.ok(
+ find('.inline-definitions .pair')
+ .textContent.trim()
+ .includes(node.status),
'Status is in additional details'
);
assert.ok(
$('[data-test-status-definition] .status-text').hasClass(`node-${node.status}`),
'Status is decorated with a status class'
);
- assert.equal(
- find('[data-test-address-definition]').textContent,
- `Address ${node.httpAddr}`,
+ assert.ok(
+ find('[data-test-address-definition]')
+ .textContent.trim()
+ .includes(node.httpAddr),
'Address is in additional details'
);
- assert.equal(
- find('[data-test-datacenter-definition]').textContent,
- `Datacenter ${node.datacenter}`,
+ assert.ok(
+ find('[data-test-datacenter-definition]')
+ .textContent.trim()
+ .includes(node.datacenter),
'Datacenter is in additional details'
);
});
@@ -330,9 +334,174 @@ test('when the node is not found, an error message is shown, but the URL persist
assert.equal(currentURL(), '/clients/not-a-real-node', 'The URL persists');
assert.ok(find('[data-test-error]'), 'Error message is shown');
assert.equal(
- find('[data-test-error-title]').textContent,
+ find('[data-test-error-title]').textContent.trim(),
'Not Found',
'Error message is for 404'
);
});
});
+
+test('/clients/:id shows the recent events list', function(assert) {
+ visit(`/clients/${node.id}`);
+
+ andThen(() => {
+ assert.ok(find('[data-test-client-events]'), 'Client events section exists');
+ });
+});
+
+test('each node event shows basic node event information', function(assert) {
+ const event = server.db.nodeEvents
+ .where({ nodeId: node.id })
+ .sortBy('time')
+ .reverse()[0];
+
+ visit(`/clients/${node.id}`);
+
+ andThen(() => {
+ const eventRow = $(find('[data-test-client-event]'));
+ assert.equal(
+ eventRow
+ .find('[data-test-client-event-time]')
+ .text()
+ .trim(),
+ moment(event.time).format('MM/DD/YY HH:mm:ss'),
+ 'Event timestamp'
+ );
+ assert.equal(
+ eventRow
+ .find('[data-test-client-event-subsystem]')
+ .text()
+ .trim(),
+ event.subsystem,
+ 'Event subsystem'
+ );
+ assert.equal(
+ eventRow
+ .find('[data-test-client-event-message]')
+ .text()
+ .trim(),
+ event.message,
+ 'Event message'
+ );
+ });
+});
+
+test('/clients/:id shows the driver status of every driver for the node', function(assert) {
+ // Set the drivers up so health and detection is well tested
+ const nodeDrivers = node.drivers;
+ const undetectedDriver = 'raw_exec';
+
+ Object.values(nodeDrivers).forEach(driver => {
+ driver.Detected = true;
+ });
+
+ nodeDrivers[undetectedDriver].Detected = false;
+ node.drivers = nodeDrivers;
+
+ const drivers = Object.keys(node.drivers)
+ .map(driverName => assign({ Name: driverName }, node.drivers[driverName]))
+ .sortBy('Name');
+
+ assert.ok(drivers.length > 0, 'Node has drivers');
+
+ visit(`/clients/${node.id}`);
+
+ andThen(() => {
+ const driverRows = findAll('[data-test-driver-status] [data-test-accordion-head]');
+
+ drivers.forEach((driver, index) => {
+ const driverRow = $(driverRows[index]);
+
+ assert.equal(
+ driverRow
+ .find('[data-test-name]')
+ .text()
+ .trim(),
+ driver.Name,
+ `${driver.Name}: Name is correct`
+ );
+ assert.equal(
+ driverRow
+ .find('[data-test-detected]')
+ .text()
+ .trim(),
+ driver.Detected ? 'Yes' : 'No',
+ `${driver.Name}: Detection is correct`
+ );
+ assert.equal(
+ driverRow
+ .find('[data-test-last-updated]')
+ .text()
+ .trim(),
+ moment(driver.UpdateTime).fromNow(),
+ `${driver.Name}: Last updated shows time since now`
+ );
+
+ if (driver.Name === undetectedDriver) {
+ assert.notOk(
+ driverRow.find('[data-test-health]').length,
+ `${driver.Name}: No health for the undetected driver`
+ );
+ } else {
+ assert.equal(
+ driverRow
+ .find('[data-test-health]')
+ .text()
+ .trim(),
+ driver.Healthy ? 'Healthy' : 'Unhealthy',
+ `${driver.Name}: Health is correct`
+ );
+ assert.ok(
+ driverRow
+ .find('[data-test-health] .color-swatch')
+ .hasClass(driver.Healthy ? 'running' : 'failed'),
+ `${driver.Name}: Swatch with correct class is shown`
+ );
+ }
+ });
+ });
+});
+
+test('each driver can be opened to see a message and attributes', function(assert) {
+ // Only detected drivers can be expanded
+ const nodeDrivers = node.drivers;
+ Object.values(nodeDrivers).forEach(driver => {
+ driver.Detected = true;
+ });
+ node.drivers = nodeDrivers;
+
+ const driver = Object.keys(node.drivers)
+ .map(driverName => assign({ Name: driverName }, node.drivers[driverName]))
+ .sortBy('Name')[0];
+
+ visit(`/clients/${node.id}`);
+
+ andThen(() => {
+ const driverBody = $(find('[data-test-driver-status] [data-test-accordion-body]'));
+ assert.notOk(
+ driverBody.find('[data-test-health-description]').length,
+ 'Driver health description is not shown'
+ );
+ assert.notOk(
+ driverBody.find('[data-test-driver-attributes]').length,
+ 'Driver attributes section is not shown'
+ );
+ click('[data-test-driver-status] [data-test-accordion-toggle]');
+ });
+
+ andThen(() => {
+ const driverBody = $(find('[data-test-driver-status] [data-test-accordion-body]'));
+ assert.equal(
+ driverBody
+ .find('[data-test-health-description]')
+ .text()
+ .trim(),
+ driver.HealthDescription,
+ 'Driver health description is now shown'
+ );
+ assert.ok(
+ driverBody.find('[data-test-driver-attributes]').length,
+ 'Driver attributes section is now shown'
+ );
+ });
+});
diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js
index 81bcf464a..7db6f6f27 100644
--- a/ui/tests/acceptance/task-group-detail-test.js
+++ b/ui/tests/acceptance/task-group-detail-test.js
@@ -237,8 +237,11 @@ test('when the allocation has reschedule events, the allocation row is denoted w
const normalRow = find(`[data-test-allocation="${allocations[1].id}"]`);
assert.ok(
- rescheduleRow.querySelector('[data-test-indicators] .icon'),
- 'Reschedule row has an icon'
+ rescheduleRow.querySelector('[data-test-indicators] [data-test-icon="reschedule"]'),
+ 'Reschedule row has a reschedule icon'
+ );
+ assert.notOk(
+ normalRow.querySelector('[data-test-indicators] [data-test-icon="reschedule"]'),
+ 'Normal row has no reschedule icon'
);
- assert.notOk(normalRow.querySelector('[data-test-indicators] .icon'), 'Normal row has no icon');
});
diff --git a/ui/tests/integration/allocation-row-test.js b/ui/tests/integration/allocation-row-test.js
index dd6478f40..3049737f2 100644
--- a/ui/tests/integration/allocation-row-test.js
+++ b/ui/tests/integration/allocation-row-test.js
@@ -4,11 +4,14 @@ import wait from 'ember-test-helpers/wait';
import hbs from 'htmlbars-inline-precompile';
import generateResources from '../../mirage/data/generate-resources';
import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage';
+import { find } from 'ember-native-dom-helpers';
import Response from 'ember-cli-mirage/response';
+import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer';
moduleForComponent('allocation-row', 'Integration | Component | allocation row', {
integration: true,
beforeEach() {
+ fragmentSerializerInitializer(getOwner(this));
this.store = getOwner(this).lookup('service:store');
this.server = startMirage();
this.server.create('namespace');
@@ -83,3 +86,40 @@ test('Allocation row polls for stats, even when it errors or has an invalid resp
);
});
});
+
+test('Allocation row shows warning when it requires drivers that are unhealthy on the node it is running on', function(assert) {
+ const node = this.server.schema.nodes.first();
+ const drivers = node.drivers;
+ Object.values(drivers).forEach(driver => {
+ driver.Healthy = false;
+ driver.Detected = true;
+ });
+ node.update({ drivers });
+
+ this.server.create('allocation');
+ this.store.findAll('job');
+ this.store.findAll('node');
+ this.store.findAll('allocation');
+
+ let allocation;
+
+ return wait()
+ .then(() => {
+ allocation = this.store.peekAll('allocation').get('firstObject');
+
+ this.setProperties({
+ allocation,
+ context: 'job',
+ });
+
+ this.render(hbs`
+ {{allocation-row
+ allocation=allocation
+ context=context}}
+ `);
+ return wait();
+ })
+ .then(() => {
+ assert.ok(find('[data-test-icon="unhealthy-driver"]'), 'Unhealthy driver icon is shown');
+ });
+});
diff --git a/ui/tests/unit/adapters/node-test.js b/ui/tests/unit/adapters/node-test.js
index fde514edd..048f8d3bd 100644
--- a/ui/tests/unit/adapters/node-test.js
+++ b/ui/tests/unit/adapters/node-test.js
@@ -9,6 +9,9 @@ moduleForAdapter('node', 'Unit | Adapter | Node', {
'adapter:node',
'model:node-attributes',
'model:allocation',
+ 'model:node-driver',
+ 'model:node-event',
+ 'model:evaluation',
'model:job',
'serializer:application',
'serializer:node',
@@ -16,6 +19,7 @@ moduleForAdapter('node', 'Unit | Adapter | Node', {
'service:config',
'service:watchList',
'transform:fragment',
+ 'transform:fragment-array',
],
beforeEach() {
this.server = startMirage();
diff --git a/ui/tests/unit/serializers/node-test.js b/ui/tests/unit/serializers/node-test.js
index f474db963..a77d59af3 100644
--- a/ui/tests/unit/serializers/node-test.js
+++ b/ui/tests/unit/serializers/node-test.js
@@ -6,7 +6,13 @@ import moduleForSerializer from '../../helpers/module-for-serializer';
import pushPayloadToStore from '../../utils/push-payload-to-store';
moduleForSerializer('node', 'Unit | Serializer | Node', {
- needs: ['serializer:node', 'service:config', 'transform:fragment', 'model:allocation'],
+ needs: [
+ 'serializer:node',
+ 'service:config',
+ 'transform:fragment',
+ 'transform:fragment-array',
+ 'model:allocation',
+ ],
});
test('local store is culled to reflect the state of findAll requests', function(assert) {
diff --git a/ui/yarn.lock b/ui/yarn.lock
index 6f921b680..795e0cf81 100644
--- a/ui/yarn.lock
+++ b/ui/yarn.lock
@@ -5691,6 +5691,10 @@ lodash.identity@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/lodash.identity/-/lodash.identity-2.3.0.tgz#6b01a210c9485355c2a913b48b6711219a173ded"
+lodash.intersection@^4.4.0:
+ version "4.4.0"
+ resolved "https://registry.yarnpkg.com/lodash.intersection/-/lodash.intersection-4.4.0.tgz#0a11ba631d0e95c23c7f2f4cbb9a692ed178e705"
+
lodash.isarguments@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
|