Skip to content

Commit 400ca29

Browse files
Merge pull request #4353 from hashicorp/f-ui-node-drain
UI: Node drain and eligibility
2 parents 265dee7 + 83e0b10 commit 400ca29

File tree

15 files changed

+414
-20
lines changed

15 files changed

+414
-20
lines changed

ui/app/helpers/format-duration.js

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import Helper from '@ember/component/helper';
2+
import formatDuration from '../utils/format-duration';
3+
4+
function formatDurationHelper([duration], { units }) {
5+
return formatDuration(duration, units);
6+
}
7+
8+
export default Helper.helper(formatDurationHelper);

ui/app/models/drain-strategy.js

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { lt, equal } from '@ember/object/computed';
2+
import attr from 'ember-data/attr';
3+
import Fragment from 'ember-data-model-fragments/fragment';
4+
5+
export default Fragment.extend({
6+
deadline: attr('number'),
7+
forceDeadline: attr('date'),
8+
ignoreSystemJobs: attr('boolean'),
9+
10+
isForced: lt('deadline', 0),
11+
hasNoDeadline: equal('deadline', 0),
12+
});

ui/app/models/node.js

+11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { computed } from '@ember/object';
2+
import { equal } from '@ember/object/computed';
23
import Model from 'ember-data/model';
34
import attr from 'ember-data/attr';
45
import { hasMany } from 'ember-data/relationships';
@@ -11,6 +12,7 @@ export default Model.extend({
1112
name: attr('string'),
1213
datacenter: attr('string'),
1314
isDraining: attr('boolean'),
15+
schedulingEligibility: attr('string'),
1416
status: attr('string'),
1517
statusDescription: attr('string'),
1618
shortId: shortUUIDProperty('id'),
@@ -23,6 +25,9 @@ export default Model.extend({
2325
meta: fragment('node-attributes'),
2426
resources: fragment('resources'),
2527
reserved: fragment('resources'),
28+
drainStrategy: fragment('drain-strategy'),
29+
30+
isEligible: equal('schedulingEligibility', 'eligible'),
2631

2732
address: computed('httpAddr', function() {
2833
return ipParts(this.get('httpAddr')).address;
@@ -52,4 +57,10 @@ export default Model.extend({
5257
unhealthyDriverNames: computed('[email protected]', function() {
5358
return this.get('unhealthyDrivers').mapBy('name');
5459
}),
60+
61+
// A status attribute that includes states not included in node status.
62+
// Useful for coloring and sorting nodes
63+
compositeStatus: computed('status', 'isEligible', function() {
64+
return this.get('isEligible') ? this.get('status') : 'ineligible';
65+
}),
5566
});

ui/app/serializers/drain-strategy.js

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import ApplicationSerializer from './application';
2+
3+
export default ApplicationSerializer.extend({
4+
normalize(typeHash, hash) {
5+
// TODO API: finishedAt is always marshaled as a date even when unset.
6+
// To simplify things, unset it here when it's the empty date value.
7+
if (hash.ForceDeadline === '0001-01-01T00:00:00Z') {
8+
hash.ForceDeadline = null;
9+
}
10+
11+
return this._super(typeHash, hash);
12+
},
13+
});

ui/app/serializers/node.js

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export default ApplicationSerializer.extend({
77
config: service(),
88

99
attrs: {
10+
isDraining: 'Drain',
1011
httpAddr: 'HTTPAddr',
1112
},
1213

ui/app/styles/components/node-status-light.scss

+4
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,8 @@ $size: 0.75em;
2626
darken($grey-lighter, 25%) 6px
2727
);
2828
}
29+
30+
&.ineligible {
31+
background: $warning;
32+
}
2933
}

ui/app/styles/components/status-text.scss

+11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
.status-text {
2+
font-weight: $weight-semibold;
3+
24
&.node-ready {
35
color: $nomad-green-dark;
46
}
@@ -10,4 +12,13 @@
1012
&.node-initializing {
1113
color: $grey;
1214
}
15+
16+
@each $name, $pair in $colors {
17+
$color: nth($pair, 1);
18+
$color-invert: nth($pair, 2);
19+
20+
&.is-#{$name} {
21+
color: $color;
22+
}
23+
}
1324
}

ui/app/templates/clients/client.hbs

+46-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{{#gutter-menu class="page-body"}}
1010
<section class="section">
1111
<h1 data-test-title class="title">
12-
<span data-test-node-status="{{model.status}}" class="node-status-light {{model.status}}"></span>
12+
<span data-test-node-status="{{model.compositeStatus}}" class="node-status-light {{model.compositeStatus}}"></span>
1313
{{or model.name model.shortId}}
1414
<span class="tag is-hollow is-small no-text-transform">{{model.id}}</span>
1515
</h1>
@@ -25,6 +25,22 @@
2525
<span class="term">Address</span>
2626
{{model.httpAddr}}
2727
</span>
28+
<span class="pair" data-test-draining>
29+
<span class="term">Draining</span>
30+
{{#if model.isDraining}}
31+
<span class="status-text is-info">true</span>
32+
{{else}}
33+
false
34+
{{/if}}
35+
</span>
36+
<span class="pair" data-test-eligibility>
37+
<span class="term">Eligibility</span>
38+
{{#if model.isEligible}}
39+
{{model.schedulingEligibility}}
40+
{{else}}
41+
<span class="status-text is-warning">{{model.schedulingEligibility}}</span>
42+
{{/if}}
43+
</span>
2844
<span class="pair" data-test-datacenter-definition>
2945
<span class="term">Datacenter</span>
3046
{{model.datacenter}}
@@ -41,6 +57,35 @@
4157
</div>
4258
</div>
4359

60+
{{#if model.drainStrategy}}
61+
<div class="boxed-section is-small is-info">
62+
<div class="boxed-section-body inline-definitions">
63+
<span class="label">Drain Strategy</span>
64+
<span class="pair" data-test-drain-deadline>
65+
<span class="term">Deadline</span>
66+
{{#if model.drainStrategy.isForced}}
67+
<span class="badge is-danger">Forced Drain</span>
68+
{{else if model.drainStrategy.hasNoDeadline}}
69+
No deadline
70+
{{else}}
71+
{{format-duration model.drainStrategy.deadline}}
72+
{{/if}}
73+
</span>
74+
{{#if model.drainStrategy.forceDeadline}}
75+
<span class="pair" data-test-drain-forced-deadline>
76+
<span class="term">Forced Deadline</span>
77+
{{moment-format model.drainStrategy.forceDeadline "MM/DD/YY HH:mm:ss"}}
78+
({{moment-from-now model.drainStrategy.forceDeadline interval=1000}})
79+
</span>
80+
{{/if}}
81+
<span class="pair" data-test-drain-ignore-system-jobs>
82+
<span class="term">Ignore System Jobs?</span>
83+
{{if model.drainStrategy.ignoreSystemJobs "Yes" "No"}}
84+
</span>
85+
</div>
86+
</div>
87+
{{/if}}
88+
4489
<div class="boxed-section">
4590
<div class="boxed-section-head">
4691
<div>Allocations <span class="badge is-white">{{model.allocations.length}}</span></div>

ui/app/templates/clients/index.hbs

+2-1
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@
2727
{{#t.sort-by prop="id"}}ID{{/t.sort-by}}
2828
{{#t.sort-by class="is-200px is-truncatable" prop="name"}}Name{{/t.sort-by}}
2929
{{#t.sort-by prop="status"}}Status{{/t.sort-by}}
30+
{{#t.sort-by prop="isDraining"}}Drain{{/t.sort-by}}
31+
{{#t.sort-by prop="schedulingEligibility"}}Eligibility{{/t.sort-by}}
3032
<th>Address</th>
31-
<th>Port</th>
3233
{{#t.sort-by prop="datacenter"}}Datacenter{{/t.sort-by}}
3334
<th># Allocs</th>
3435
{{/t.head}}

ui/app/templates/components/client-node-row.hbs

+15-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,21 @@
88
<td data-test-client-id>{{#link-to "clients.client" node.id class="is-primary"}}{{node.shortId}}{{/link-to}}</td>
99
<td data-test-client-name class="is-200px is-truncatable" title="{{node.name}}">{{node.name}}</td>
1010
<td data-test-client-status>{{node.status}}</td>
11-
<td data-test-client-address>{{node.address}}</td>
12-
<td data-test-client-port>{{node.port}}</td>
11+
<td data-test-client-drain>
12+
{{#if node.isDraining}}
13+
<span class="status-text is-info">true</span>
14+
{{else}}
15+
false
16+
{{/if}}
17+
</td>
18+
<td data-test-client-eligibility>
19+
{{#if node.isEligible}}
20+
{{node.schedulingEligibility}}
21+
{{else}}
22+
<span class="status-text is-warning">{{node.schedulingEligibility}}</span>
23+
{{/if}}
24+
</td>
25+
<td data-test-client-address>{{node.httpAddr}}</td>
1326
<td data-test-client-datacenter>{{node.datacenter}}</td>
1427
<td data-test-client-allocations>
1528
{{#if node.allocations.isPending}}

ui/app/utils/format-duration.js

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import moment from 'moment';
2+
3+
const allUnits = [
4+
{ name: 'years', suffix: 'year', inMoment: true, pluralizable: true },
5+
{ name: 'months', suffix: 'month', inMoment: true, pluralizable: true },
6+
{ name: 'days', suffix: 'day', inMoment: true, pluralizable: true },
7+
{ name: 'hours', suffix: 'h', inMoment: true, pluralizable: false },
8+
{ name: 'minutes', suffix: 'm', inMoment: true, pluralizable: false },
9+
{ name: 'seconds', suffix: 's', inMoment: true, pluralizable: false },
10+
{ name: 'milliseconds', suffix: 'ms', inMoment: true, pluralizable: false },
11+
{ name: 'microseconds', suffix: 'µs', inMoment: false, pluralizable: false },
12+
{ name: 'nanoseconds', suffix: 'ns', inMoment: false, pluralizable: false },
13+
];
14+
15+
export default function formatDuration(duration = 0, units = 'ns') {
16+
const durationParts = {};
17+
18+
// Moment only handles up to millisecond precision.
19+
// Microseconds and nanoseconds need to be handled first,
20+
// then Moment can take over for all larger units.
21+
if (units === 'ns') {
22+
durationParts.nanoseconds = duration % 1000;
23+
durationParts.microseconds = Math.floor((duration % 1000000) / 1000);
24+
duration = Math.floor(duration / 1000000);
25+
} else if (units === 'mms') {
26+
durationParts.microseconds = duration % 1000;
27+
duration = Math.floor(duration / 1000);
28+
}
29+
30+
let momentUnits = units;
31+
if (units === 'ns' || units === 'mms') {
32+
momentUnits = 'ms';
33+
}
34+
const momentDuration = moment.duration(duration, momentUnits);
35+
36+
// Get the count of each time unit that Moment handles
37+
allUnits
38+
.filterBy('inMoment')
39+
.mapBy('name')
40+
.forEach(unit => {
41+
durationParts[unit] = momentDuration[unit]();
42+
});
43+
44+
// Format each time time bucket as a string
45+
// e.g., { years: 5, seconds: 30 } -> [ '5 years', '30s' ]
46+
const displayParts = allUnits.reduce((parts, unitType) => {
47+
if (durationParts[unitType.name]) {
48+
const count = durationParts[unitType.name];
49+
const suffix =
50+
count === 1 || !unitType.pluralizable ? unitType.suffix : unitType.suffix.pluralize();
51+
parts.push(`${count}${unitType.pluralizable ? ' ' : ''}${suffix}`);
52+
}
53+
return parts;
54+
}, []);
55+
56+
if (displayParts.length) {
57+
return displayParts.join(' ');
58+
}
59+
60+
// When the duration is 0, show 0 in terms of `units`
61+
const unitTypeForUnits = allUnits.findBy('suffix', units);
62+
const suffix = unitTypeForUnits.pluralizable ? units.pluralize() : units;
63+
return `0${unitTypeForUnits.pluralizable ? ' ' : ''}${suffix}`;
64+
}

ui/mirage/factories/node.js

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Factory, faker, trait } from 'ember-cli-mirage';
22
import { provide } from '../utils';
33
import { DATACENTERS, HOSTS } from '../common';
4+
import moment from 'moment';
45

56
const UUIDS = provide(100, faker.random.uuid.bind(faker.random));
67
const NODE_STATUSES = ['initializing', 'ready', 'down'];
@@ -11,9 +12,10 @@ export default Factory.extend({
1112
name: i => `nomad@${HOSTS[i % HOSTS.length]}`,
1213

1314
datacenter: faker.list.random(...DATACENTERS),
14-
isDraining: faker.random.boolean,
15+
drain: faker.random.boolean,
1516
status: faker.list.random(...NODE_STATUSES),
1617
tls_enabled: faker.random.boolean,
18+
schedulingEligibility: () => (faker.random.boolean() ? 'eligible' : 'ineligible'),
1719

1820
createIndex: i => i,
1921
modifyIndex: () => faker.random.number({ min: 10, max: 2000 }),
@@ -29,6 +31,38 @@ export default Factory.extend({
2931
},
3032
}),
3133

34+
draining: trait({
35+
drain: true,
36+
schedulingEligibility: 'ineligible',
37+
drainStrategy: {
38+
Deadline: faker.random.number({ min: 30 * 1000, max: 5 * 60 * 60 * 1000 }) * 1000000,
39+
ForceDeadline: moment(REF_DATE).add(faker.random.number({ min: 1, max: 5 }), 'd'),
40+
IgnoreSystemJobs: faker.random.boolean(),
41+
},
42+
}),
43+
44+
forcedDraining: trait({
45+
drain: true,
46+
schedulingEligibility: 'ineligible',
47+
drainStrategy: {
48+
Deadline: -1,
49+
ForceDeadline: '0001-01-01T00:00:00Z',
50+
IgnoreSystemJobs: faker.random.boolean(),
51+
},
52+
}),
53+
54+
noDeadlineDraining: trait({
55+
drain: true,
56+
schedulingEligibility: 'ineligible',
57+
drainStrategy: {
58+
Deadline: 0,
59+
ForceDeadline: '0001-01-01T00:00:00Z',
60+
IgnoreSystemJobs: faker.random.boolean(),
61+
},
62+
}),
63+
64+
drainStrategy: null,
65+
3266
drivers: makeDrivers,
3367

3468
attributes() {

0 commit comments

Comments
 (0)