Merge pull request #4294 from hashicorp/f-ui-driver-health-checking

UI: Driver health checking
This commit is contained in:
Michael Lange
2018-05-25 12:00:11 -07:00
committed by GitHub
41 changed files with 742 additions and 34 deletions

View File

@@ -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(() => []),
});

View File

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

View File

@@ -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() {},
});

View File

@@ -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);

View File

@@ -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'),
});

View File

@@ -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'),
});

View File

@@ -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`)

View File

@@ -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()

View File

@@ -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';
}),
});

View File

@@ -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'),
});

View File

@@ -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');
}),
});

View File

@@ -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'));
}),

View File

@@ -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'),

View File

@@ -0,0 +1,7 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
attrs: {
time: 'Timestamp',
},
});

View File

@@ -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);
},

View File

@@ -1,3 +1,4 @@
@import './components/accordion';
@import './components/badge';
@import './components/boxed-section';
@import './components/cli-window';

View File

@@ -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;
}
}
}

View File

@@ -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%);
}
}

View File

@@ -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.

View File

@@ -40,6 +40,7 @@
sortDescending=sortDescending
class="is-striped" as |t|}}
{{#t.head}}
<th class="is-narrow"></th>
{{#t.sort-by prop="name"}}Name{{/t.sort-by}}
{{#t.sort-by prop="state"}}State{{/t.sort-by}}
<th>Last Event</th>
@@ -48,6 +49,13 @@
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-task-row={{row.model.task.name}}>
<td class="is-narrow">
{{#if (not row.model.driverStatus.healthy)}}
<span data-test-icon="unhealthy-driver" class="tooltip text-center" aria-label="{{row.model.driver}} is unhealthy">
{{x-icon "warning" class="is-warning"}}
</span>
{{/if}}
</td>
<td data-test-name>
{{#link-to "allocations.allocation.task" row.model.allocation row.model}}
{{row.model.name}}

View File

@@ -17,9 +17,27 @@
<div class="boxed-section is-small">
<div class="boxed-section-body inline-definitions">
<span class="label">Client Details</span>
<span class="pair" data-test-status-definition><span class="term">Status</span> <span class="status-text node-{{model.status}}">{{model.status}}</span></span>
<span class="pair" data-test-address-definition><span class="term">Address</span> {{model.httpAddr}}</span>
<span class="pair" data-test-datacenter-definition><span class="term">Datacenter</span> {{model.datacenter}}</span>
<span class="pair" data-test-status-definition>
<span class="term">Status</span>
<span class="status-text node-{{model.status}}">{{model.status}}</span>
</span>
<span class="pair" data-test-address-definition>
<span class="term">Address</span>
{{model.httpAddr}}
</span>
<span class="pair" data-test-datacenter-definition>
<span class="term">Datacenter</span>
{{model.datacenter}}
</span>
<span class="pair" data-test-driver-health>
<span class="term">Drivers</span>
{{#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}}
</span>
</div>
</div>
@@ -75,6 +93,95 @@
</div>
</div>
<div data-test-client-events class="boxed-section">
<div class="boxed-section-head">
Client Events
</div>
<div class="boxed-section-body is-full-bleed">
{{#list-table source=sortedEvents class="is-striped" as |t|}}
{{#t.head}}
<th class="is-2">Time</th>
<th class="is-2">Subsystem</th>
<th>Message</th>
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-client-event>
<td data-test-client-event-time>{{moment-format row.model.time "MM/DD/YY HH:mm:ss"}}</td>
<td data-test-client-event-subsystem>{{row.model.subsystem}}</td>
<td data-test-client-event-message>
{{#if row.model.message}}
{{#if row.model.driver}}
<span class="badge is-secondary is-small">{{row.model.driver}}</span>
{{/if}}
{{row.model.message}}
{{else}}
<em>No message</em>
{{/if}}
</td>
</tr>
{{/t.body}}
{{/list-table}}
</div>
</div>
<div data-test-driver-status class="boxed-section">
<div class="boxed-section-head">
Driver Status
</div>
<div class="boxed-section-body">
{{#list-accordion source=sortedDrivers key="name" as |a|}}
{{#a.head buttonLabel="details" isExpandable=a.item.detected}}
<div class="columns inline-definitions {{unless a.item.detected "is-faded"}}">
<div class="column is-1">
<span data-test-name>{{a.item.name}}</span>
</div>
<div class="column is-2">
{{#if a.item.detected}}
<span data-test-health>
<span class="color-swatch {{a.item.healthClass}}"></span>
{{if a.item.healthy "Healthy" "Unhealthy"}}
</span>
{{/if}}
</div>
<div class="column">
<span class="pair">
<span class="term">Detected</span>
<span data-test-detected>{{if a.item.detected "Yes" "No"}}</span>
</span>
<span class="is-pulled-right">
<span class="pair">
<span class="term">Last Updated</span>
<span data-test-last-updated>{{moment-from-now a.item.updateTime interval=1000}}</span>
</span>
</span>
</div>
</div>
{{/a.head}}
{{#a.body}}
<p data-test-health-description class="message">{{a.item.healthDescription}}</p>
<div data-test-driver-attributes class="boxed-section">
<div class="boxed-section-head">
{{capitalize a.item.name}} Attributes
</div>
{{#if a.item.attributes.attributesStructured}}
<div class="boxed-section-body is-full-bleed">
{{attributes-table
attributes=a.item.attributesShort
class="attributes-table"}}
</div>
{{else}}
<div class="boxed-section-body">
<div class="empty-message">
<h3 class="empty-message-headline">No Driver Attributes</h3>
</div>
</div>
{{/if}}
</div>
{{/a.body}}
{{/list-accordion}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Attributes

View File

@@ -23,6 +23,7 @@
sortDescending=sortDescending
class="with-foot" as |t|}}
{{#t.head}}
<th class="is-narrow"></th>
{{#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}}

View File

@@ -1,6 +1,11 @@
<td data-test-indicators class="is-narrow">
{{#if allocation.unhealthyDrivers.length}}
<span data-test-icon="unhealthy-driver" class="tooltip text-center" aria-label="Allocation depends on unhealthy drivers">
{{x-icon "warning" class="is-warning"}}
</span>
{{/if}}
{{#if allocation.nextAllocation}}
<span class="tooltip text-center" aria-label="Allocation was rescheduled">
<span data-test-icon="reschedule" class="tooltip text-center" aria-label="Allocation was rescheduled">
{{x-icon "history" class="is-faded"}}
</span>
{{/if}}

View File

@@ -1,3 +1,10 @@
<td data-test-icon class="is-narrow">
{{#if node.unhealthyDrivers.length}}
<span class="tooltip text-center" aria-label="Client has unhealthy drivers">
{{x-icon "warning" class="is-warning"}}
</span>
{{/if}}
</td>
<td data-test-client-id>{{#link-to "clients.client" node.id class="is-primary"}}{{node.shortId}}{{/link-to}}</td>
<td data-test-client-name class="is-200px is-truncatable" title="{{node.name}}">{{node.name}}</td>
<td data-test-client-status>{{node.status}}</td>

View File

@@ -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}}

View File

@@ -0,0 +1,5 @@
{{#if isOpen}}
<div data-test-accordion-body class="accordion-body">
{{yield}}
</div>
{{/if}}

View File

@@ -0,0 +1,9 @@
<div class="accordion-head-content">
{{yield}}
</div>
<button
data-test-accordion-toggle
class="button is-light is-compact pull-right accordion-toggle {{unless isExpandable "is-invisible"}}"
onclick={{action (if isOpen onClose onOpen) item}}>
{{buttonLabel}}
</button>

View File

@@ -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,
});

View File

@@ -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'),
};
}

View File

@@ -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();

View File

@@ -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,
});

5
ui/mirage/models/node.js Normal file
View File

@@ -0,0 +1,5 @@
import { Model, hasMany } from 'ember-cli-mirage';
export default Model.extend({
events: hasMany('node-event'),
});

View File

@@ -0,0 +1,6 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
embed: true,
include: ['events'],
});

View File

@@ -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"
},

View File

@@ -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');
});

View File

@@ -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'
);
});
});

View File

@@ -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');
});

View File

@@ -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');
});
});

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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"