diff --git a/ui/app/helpers/format-duration.js b/ui/app/helpers/format-duration.js new file mode 100644 index 000000000000..c85a14b41e91 --- /dev/null +++ b/ui/app/helpers/format-duration.js @@ -0,0 +1,8 @@ +import Helper from '@ember/component/helper'; +import formatDuration from '../utils/format-duration'; + +function formatDurationHelper([duration], { units }) { + return formatDuration(duration, units); +} + +export default Helper.helper(formatDurationHelper); diff --git a/ui/app/models/drain-strategy.js b/ui/app/models/drain-strategy.js new file mode 100644 index 000000000000..4d2738961879 --- /dev/null +++ b/ui/app/models/drain-strategy.js @@ -0,0 +1,12 @@ +import { lt, equal } from '@ember/object/computed'; +import attr from 'ember-data/attr'; +import Fragment from 'ember-data-model-fragments/fragment'; + +export default Fragment.extend({ + deadline: attr('number'), + forceDeadline: attr('date'), + ignoreSystemJobs: attr('boolean'), + + isForced: lt('deadline', 0), + hasNoDeadline: equal('deadline', 0), +}); diff --git a/ui/app/models/node.js b/ui/app/models/node.js index 4dbb4fec6f99..93ac0038f525 100644 --- a/ui/app/models/node.js +++ b/ui/app/models/node.js @@ -1,4 +1,5 @@ import { computed } from '@ember/object'; +import { equal } from '@ember/object/computed'; import Model from 'ember-data/model'; import attr from 'ember-data/attr'; import { hasMany } from 'ember-data/relationships'; @@ -11,6 +12,7 @@ export default Model.extend({ name: attr('string'), datacenter: attr('string'), isDraining: attr('boolean'), + schedulingEligibility: attr('string'), status: attr('string'), statusDescription: attr('string'), shortId: shortUUIDProperty('id'), @@ -23,6 +25,9 @@ export default Model.extend({ meta: fragment('node-attributes'), resources: fragment('resources'), reserved: fragment('resources'), + drainStrategy: fragment('drain-strategy'), + + isEligible: equal('schedulingEligibility', 'eligible'), address: computed('httpAddr', function() { return ipParts(this.get('httpAddr')).address; @@ -52,4 +57,10 @@ export default Model.extend({ unhealthyDriverNames: computed('unhealthyDrivers.@each.name', function() { return this.get('unhealthyDrivers').mapBy('name'); }), + + // A status attribute that includes states not included in node status. + // Useful for coloring and sorting nodes + compositeStatus: computed('status', 'isEligible', function() { + return this.get('isEligible') ? this.get('status') : 'ineligible'; + }), }); diff --git a/ui/app/serializers/drain-strategy.js b/ui/app/serializers/drain-strategy.js new file mode 100644 index 000000000000..033cc45a6582 --- /dev/null +++ b/ui/app/serializers/drain-strategy.js @@ -0,0 +1,13 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + normalize(typeHash, hash) { + // TODO API: finishedAt is always marshaled as a date even when unset. + // To simplify things, unset it here when it's the empty date value. + if (hash.ForceDeadline === '0001-01-01T00:00:00Z') { + hash.ForceDeadline = null; + } + + return this._super(typeHash, hash); + }, +}); diff --git a/ui/app/serializers/node.js b/ui/app/serializers/node.js index a8d3410e2d9f..e6a1f2c22cbf 100644 --- a/ui/app/serializers/node.js +++ b/ui/app/serializers/node.js @@ -7,6 +7,7 @@ export default ApplicationSerializer.extend({ config: service(), attrs: { + isDraining: 'Drain', httpAddr: 'HTTPAddr', }, diff --git a/ui/app/styles/components/node-status-light.scss b/ui/app/styles/components/node-status-light.scss index 8acb0cfce79c..9077e333a8f0 100644 --- a/ui/app/styles/components/node-status-light.scss +++ b/ui/app/styles/components/node-status-light.scss @@ -26,4 +26,8 @@ $size: 0.75em; darken($grey-lighter, 25%) 6px ); } + + &.ineligible { + background: $warning; + } } diff --git a/ui/app/styles/components/status-text.scss b/ui/app/styles/components/status-text.scss index 7e49ab4e103a..b8a49ce1cfc0 100644 --- a/ui/app/styles/components/status-text.scss +++ b/ui/app/styles/components/status-text.scss @@ -1,4 +1,6 @@ .status-text { + font-weight: $weight-semibold; + &.node-ready { color: $nomad-green-dark; } @@ -10,4 +12,13 @@ &.node-initializing { color: $grey; } + + @each $name, $pair in $colors { + $color: nth($pair, 1); + $color-invert: nth($pair, 2); + + &.is-#{$name} { + color: $color; + } + } } diff --git a/ui/app/templates/clients/client.hbs b/ui/app/templates/clients/client.hbs index f5ad36c9f491..af3d918fa7a4 100644 --- a/ui/app/templates/clients/client.hbs +++ b/ui/app/templates/clients/client.hbs @@ -9,7 +9,7 @@ {{#gutter-menu class="page-body"}}

- + {{or model.name model.shortId}} {{model.id}}

@@ -25,6 +25,22 @@ Address {{model.httpAddr}} + + Draining + {{#if model.isDraining}} + true + {{else}} + false + {{/if}} + + + Eligibility + {{#if model.isEligible}} + {{model.schedulingEligibility}} + {{else}} + {{model.schedulingEligibility}} + {{/if}} + Datacenter {{model.datacenter}} @@ -41,6 +57,35 @@ + {{#if model.drainStrategy}} +
+
+ Drain Strategy + + Deadline + {{#if model.drainStrategy.isForced}} + Forced Drain + {{else if model.drainStrategy.hasNoDeadline}} + No deadline + {{else}} + {{format-duration model.drainStrategy.deadline}} + {{/if}} + + {{#if model.drainStrategy.forceDeadline}} + + Forced Deadline + {{moment-format model.drainStrategy.forceDeadline "MM/DD/YY HH:mm:ss"}} + ({{moment-from-now model.drainStrategy.forceDeadline interval=1000}}) + + {{/if}} + + Ignore System Jobs? + {{if model.drainStrategy.ignoreSystemJobs "Yes" "No"}} + +
+
+ {{/if}} +
Allocations {{model.allocations.length}}
diff --git a/ui/app/templates/clients/index.hbs b/ui/app/templates/clients/index.hbs index 888c05634207..83ccc379619d 100644 --- a/ui/app/templates/clients/index.hbs +++ b/ui/app/templates/clients/index.hbs @@ -27,8 +27,9 @@ {{#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}} + {{#t.sort-by prop="isDraining"}}Drain{{/t.sort-by}} + {{#t.sort-by prop="schedulingEligibility"}}Eligibility{{/t.sort-by}} Address - Port {{#t.sort-by prop="datacenter"}}Datacenter{{/t.sort-by}} # Allocs {{/t.head}} diff --git a/ui/app/templates/components/client-node-row.hbs b/ui/app/templates/components/client-node-row.hbs index 9a946006dfe1..269e522a39c5 100644 --- a/ui/app/templates/components/client-node-row.hbs +++ b/ui/app/templates/components/client-node-row.hbs @@ -8,8 +8,21 @@ {{#link-to "clients.client" node.id class="is-primary"}}{{node.shortId}}{{/link-to}} {{node.name}} {{node.status}} -{{node.address}} -{{node.port}} + + {{#if node.isDraining}} + true + {{else}} + false + {{/if}} + + + {{#if node.isEligible}} + {{node.schedulingEligibility}} + {{else}} + {{node.schedulingEligibility}} + {{/if}} + +{{node.httpAddr}} {{node.datacenter}} {{#if node.allocations.isPending}} diff --git a/ui/app/utils/format-duration.js b/ui/app/utils/format-duration.js new file mode 100644 index 000000000000..2f8ea1b72b26 --- /dev/null +++ b/ui/app/utils/format-duration.js @@ -0,0 +1,64 @@ +import moment from 'moment'; + +const allUnits = [ + { name: 'years', suffix: 'year', inMoment: true, pluralizable: true }, + { name: 'months', suffix: 'month', inMoment: true, pluralizable: true }, + { name: 'days', suffix: 'day', inMoment: true, pluralizable: true }, + { name: 'hours', suffix: 'h', inMoment: true, pluralizable: false }, + { name: 'minutes', suffix: 'm', inMoment: true, pluralizable: false }, + { name: 'seconds', suffix: 's', inMoment: true, pluralizable: false }, + { name: 'milliseconds', suffix: 'ms', inMoment: true, pluralizable: false }, + { name: 'microseconds', suffix: 'µs', inMoment: false, pluralizable: false }, + { name: 'nanoseconds', suffix: 'ns', inMoment: false, pluralizable: false }, +]; + +export default function formatDuration(duration = 0, units = 'ns') { + const durationParts = {}; + + // Moment only handles up to millisecond precision. + // Microseconds and nanoseconds need to be handled first, + // then Moment can take over for all larger units. + if (units === 'ns') { + durationParts.nanoseconds = duration % 1000; + durationParts.microseconds = Math.floor((duration % 1000000) / 1000); + duration = Math.floor(duration / 1000000); + } else if (units === 'mms') { + durationParts.microseconds = duration % 1000; + duration = Math.floor(duration / 1000); + } + + let momentUnits = units; + if (units === 'ns' || units === 'mms') { + momentUnits = 'ms'; + } + const momentDuration = moment.duration(duration, momentUnits); + + // Get the count of each time unit that Moment handles + allUnits + .filterBy('inMoment') + .mapBy('name') + .forEach(unit => { + durationParts[unit] = momentDuration[unit](); + }); + + // Format each time time bucket as a string + // e.g., { years: 5, seconds: 30 } -> [ '5 years', '30s' ] + const displayParts = allUnits.reduce((parts, unitType) => { + if (durationParts[unitType.name]) { + const count = durationParts[unitType.name]; + const suffix = + count === 1 || !unitType.pluralizable ? unitType.suffix : unitType.suffix.pluralize(); + parts.push(`${count}${unitType.pluralizable ? ' ' : ''}${suffix}`); + } + return parts; + }, []); + + if (displayParts.length) { + return displayParts.join(' '); + } + + // When the duration is 0, show 0 in terms of `units` + const unitTypeForUnits = allUnits.findBy('suffix', units); + const suffix = unitTypeForUnits.pluralizable ? units.pluralize() : units; + return `0${unitTypeForUnits.pluralizable ? ' ' : ''}${suffix}`; +} diff --git a/ui/mirage/factories/node.js b/ui/mirage/factories/node.js index 3950f36928f1..13c42736cf77 100644 --- a/ui/mirage/factories/node.js +++ b/ui/mirage/factories/node.js @@ -1,6 +1,7 @@ import { Factory, faker, trait } from 'ember-cli-mirage'; import { provide } from '../utils'; import { DATACENTERS, HOSTS } from '../common'; +import moment from 'moment'; const UUIDS = provide(100, faker.random.uuid.bind(faker.random)); const NODE_STATUSES = ['initializing', 'ready', 'down']; @@ -11,9 +12,10 @@ export default Factory.extend({ name: i => `nomad@${HOSTS[i % HOSTS.length]}`, datacenter: faker.list.random(...DATACENTERS), - isDraining: faker.random.boolean, + drain: faker.random.boolean, status: faker.list.random(...NODE_STATUSES), tls_enabled: faker.random.boolean, + schedulingEligibility: () => (faker.random.boolean() ? 'eligible' : 'ineligible'), createIndex: i => i, modifyIndex: () => faker.random.number({ min: 10, max: 2000 }), @@ -29,6 +31,38 @@ export default Factory.extend({ }, }), + draining: trait({ + drain: true, + schedulingEligibility: 'ineligible', + drainStrategy: { + Deadline: faker.random.number({ min: 30 * 1000, max: 5 * 60 * 60 * 1000 }) * 1000000, + ForceDeadline: moment(REF_DATE).add(faker.random.number({ min: 1, max: 5 }), 'd'), + IgnoreSystemJobs: faker.random.boolean(), + }, + }), + + forcedDraining: trait({ + drain: true, + schedulingEligibility: 'ineligible', + drainStrategy: { + Deadline: -1, + ForceDeadline: '0001-01-01T00:00:00Z', + IgnoreSystemJobs: faker.random.boolean(), + }, + }), + + noDeadlineDraining: trait({ + drain: true, + schedulingEligibility: 'ineligible', + drainStrategy: { + Deadline: 0, + ForceDeadline: '0001-01-01T00:00:00Z', + IgnoreSystemJobs: faker.random.boolean(), + }, + }), + + drainStrategy: null, + drivers: makeDrivers, attributes() { diff --git a/ui/tests/acceptance/client-detail-test.js b/ui/tests/acceptance/client-detail-test.js index 357779a56abd..f1860c610332 100644 --- a/ui/tests/acceptance/client-detail-test.js +++ b/ui/tests/acceptance/client-detail-test.js @@ -4,13 +4,14 @@ import { click, find, findAll, currentURL, visit } from 'ember-native-dom-helper import { test } from 'qunit'; import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance'; import { formatBytes } from 'nomad-ui/helpers/format-bytes'; +import formatDuration from 'nomad-ui/utils/format-duration'; import moment from 'moment'; let node; moduleForAcceptance('Acceptance | client detail', { beforeEach() { - server.create('node', 'forceIPv4'); + server.create('node', 'forceIPv4', { schedulingEligibility: 'eligible' }); node = server.db.nodes[0]; // Related models @@ -75,6 +76,18 @@ test('/clients/:id should list additional detail for the node below the title', .includes(node.httpAddr), 'Address is in additional details' ); + assert.ok( + find('[data-test-draining]') + .textContent.trim() + .includes(node.drain + ''), + 'Drain status is in additional details' + ); + assert.ok( + find('[data-test-eligibility]') + .textContent.trim() + .includes(node.schedulingEligibility), + 'Scheduling eligibility is in additional details' + ); assert.ok( find('[data-test-datacenter-definition]') .textContent.trim() @@ -505,3 +518,139 @@ test('each driver can be opened to see a message and attributes', function(asser ); }); }); + +test('the status light indicates when the node is ineligible for scheduling', function(assert) { + node = server.create('node', { + schedulingEligibility: 'ineligible', + }); + + visit(`/clients/${node.id}`); + + andThen(() => { + assert.ok( + find('[data-test-node-status="ineligible"]'), + 'Title status light is in the ineligible state' + ); + }); +}); + +test('when the node has a drain strategy with a positive deadline, the drain stategy section prints the duration', function(assert) { + const deadline = 5400000000000; // 1.5 hours in nanoseconds + const forceDeadline = moment().add(1, 'd'); + + node = server.create('node', { + drain: true, + schedulingEligibility: 'ineligible', + drainStrategy: { + Deadline: deadline, + ForceDeadline: forceDeadline.toISOString(), + IgnoreSystemJobs: false, + }, + }); + + visit(`/clients/${node.id}`); + + andThen(() => { + assert.ok( + find('[data-test-drain-deadline]') + .textContent.trim() + .includes(formatDuration(deadline)), + 'Deadline is shown in a human formatted way' + ); + + assert.ok( + find('[data-test-drain-forced-deadline]') + .textContent.trim() + .includes(forceDeadline.format('MM/DD/YY HH:mm:ss')), + 'Force deadline is shown as an absolute date' + ); + + assert.ok( + find('[data-test-drain-forced-deadline]') + .textContent.trim() + .includes(forceDeadline.fromNow()), + 'Force deadline is shown as a relative date' + ); + + assert.ok( + find('[data-test-drain-ignore-system-jobs]') + .textContent.trim() + .endsWith('No'), + 'Ignore System Jobs state is shown' + ); + }); +}); + +test('when the node has a drain stategy with no deadline, the drain stategy section mentions that and omits the force deadline', function(assert) { + const deadline = 0; + + node = server.create('node', { + drain: true, + schedulingEligibility: 'ineligible', + drainStrategy: { + Deadline: deadline, + ForceDeadline: '0001-01-01T00:00:00Z', // null as a date + IgnoreSystemJobs: true, + }, + }); + + visit(`/clients/${node.id}`); + + andThen(() => { + assert.ok( + find('[data-test-drain-deadline]') + .textContent.trim() + .includes('No deadline'), + 'The value for Deadline is "no deadline"' + ); + + assert.notOk( + find('[data-test-drain-forced-deadline]'), + 'Forced deadline is not shown since there is no forced deadline' + ); + + assert.ok( + find('[data-test-drain-ignore-system-jobs]') + .textContent.trim() + .endsWith('Yes'), + 'Ignore System Jobs state is shown' + ); + }); +}); + +test('when the node has a drain stategy with a negative deadline, the drain strategy section shows the force badge', function(assert) { + const deadline = -1; + + node = server.create('node', { + drain: true, + schedulingEligibility: 'ineligible', + drainStrategy: { + Deadline: deadline, + ForceDeadline: '0001-01-01T00:00:00Z', // null as a date + IgnoreSystemJobs: false, + }, + }); + + visit(`/clients/${node.id}`); + + andThen(() => { + assert.ok( + find('[data-test-drain-deadline] .badge.is-danger') + .textContent.trim() + .includes('Forced Drain'), + 'Forced Drain is shown in a red badge' + ); + + assert.notOk( + find('[data-test-drain-forced-deadline]'), + 'Forced deadline is not shown since there is no forced deadline' + ); + + assert.ok( + find('[data-test-drain-ignore-system-jobs]') + .textContent.trim() + .endsWith('No'), + 'Ignore System Jobs state is shown' + ); + }); +}); diff --git a/ui/tests/acceptance/nodes-list-test.js b/ui/tests/acceptance/nodes-list-test.js index dde20b744ae7..837e8cbe6ce0 100644 --- a/ui/tests/acceptance/nodes-list-test.js +++ b/ui/tests/acceptance/nodes-list-test.js @@ -2,7 +2,6 @@ import { click, find, findAll, currentURL, visit } from 'ember-native-dom-helper import { test } from 'qunit'; import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance'; import { findLeader } from '../../mirage/config'; -import ipParts from 'nomad-ui/utils/ip-parts'; function minimumSetup() { server.createList('node', 1); @@ -47,7 +46,6 @@ test('each client record should show high-level info of the client', function(as andThen(() => { const nodeRow = find('[data-test-client-node-row]'); const allocations = server.db.allocations.where({ nodeId: node.id }); - const { address, port } = ipParts(node.httpAddr); assert.equal( nodeRow.querySelector('[data-test-client-id]').textContent.trim(), @@ -64,12 +62,20 @@ test('each client record should show high-level info of the client', function(as node.status, 'Status' ); + assert.equal( + nodeRow.querySelector('[data-test-client-drain]').textContent.trim(), + node.drain + '', + 'Draining' + ); + assert.equal( + nodeRow.querySelector('[data-test-client-eligibility]').textContent.trim(), + node.schedulingEligibility, + 'Eligibility' + ); assert.equal( nodeRow.querySelector('[data-test-client-address]').textContent.trim(), - address, - 'Address' + node.httpAddr ); - assert.equal(nodeRow.querySelector('[data-test-client-port]').textContent.trim(), port, 'Port'); assert.equal( nodeRow.querySelector('[data-test-client-datacenter]').textContent.trim(), node.datacenter, @@ -108,9 +114,7 @@ test('when there are no clients, there is an empty message', function(assert) { }); }); -test('when there are clients, but no matches for a search term, there is an empty message', function( - assert -) { +test('when there are clients, but no matches for a search term, there is an empty message', function(assert) { server.createList('agent', 1); server.create('node', { name: 'node' }); @@ -126,9 +130,7 @@ test('when there are clients, but no matches for a search term, there is an empt }); }); -test('when accessing clients is forbidden, show a message with a link to the tokens page', function( - assert -) { +test('when accessing clients is forbidden, show a message with a link to the tokens page', function(assert) { server.create('agent'); server.create('node', { name: 'node' }); server.pretender.get('/v1/nodes', () => [403, {}, null]); @@ -236,9 +238,7 @@ test('each server should link to the server detail page', function(assert) { }); }); -test('when accessing servers is forbidden, show a message with a link to the tokens page', function( - assert -) { +test('when accessing servers is forbidden, show a message with a link to the tokens page', function(assert) { server.create('agent'); server.pretender.get('/v1/agent/members', () => [403, {}, null]); diff --git a/ui/tests/unit/utils/format-duration-test.js b/ui/tests/unit/utils/format-duration-test.js new file mode 100644 index 000000000000..c4867590f32d --- /dev/null +++ b/ui/tests/unit/utils/format-duration-test.js @@ -0,0 +1,28 @@ +import { module, test } from 'ember-qunit'; +import formatDuration from 'nomad-ui/utils/format-duration'; + +module('Unit | Util | formatDuration'); + +test('When all units have values, all units are displayed', function(assert) { + const expectation = '39 years 1 month 13 days 23h 31m 30s 987ms 654µs 400ns'; + assert.equal(formatDuration(1234567890987654321), expectation, expectation); +}); + +test('Any unit without values gets dropped from the display', function(assert) { + const expectation = '14 days 6h 56m 890ms 980µs'; + assert.equal(formatDuration(1234560890980000), expectation, expectation); +}); + +test('The units option allows for units coarser than nanoseconds', function(assert) { + const expectation1 = '1s 200ms'; + const expectation2 = '20m'; + const expectation3 = '1 month 1 day'; + assert.equal(formatDuration(1200, 'ms'), expectation1, expectation1); + assert.equal(formatDuration(1200, 's'), expectation2, expectation2); + assert.equal(formatDuration(32, 'd'), expectation3, expectation3); +}); + +test('When duration is 0, 0 is shown in terms of the units provided to the function', function(assert) { + assert.equal(formatDuration(0), '0ns', 'formatDuration(0) -> 0ns'); + assert.equal(formatDuration(0, 'year'), '0 years', 'formatDuration(0, "year") -> 0 years'); +});