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"