diff --git a/ui/app/components/client-node-row.js b/ui/app/components/client-node-row.js index 947ae6e9a823..07562358835e 100644 --- a/ui/app/components/client-node-row.js +++ b/ui/app/components/client-node-row.js @@ -3,6 +3,7 @@ import Component from '@ember/component'; import { lazyClick } from '../helpers/lazy-click'; import { watchRelationship } from 'nomad-ui/utils/properties/watch'; import WithVisibilityDetection from 'nomad-ui/mixins/with-component-visibility-detection'; +import { computed } from '@ember/object'; export default Component.extend(WithVisibilityDetection, { store: service(), @@ -45,4 +46,16 @@ export default Component.extend(WithVisibilityDetection, { }, watch: watchRelationship('allocations'), + + compositeStatusClass: computed('node.compositeStatus', function() { + let compositeStatus = this.get('node.compositeStatus'); + + if (compositeStatus === 'draining') { + return 'status-text is-info'; + } else if (compositeStatus === 'ineligible') { + return 'status-text is-warning'; + } else { + return ''; + } + }), }); diff --git a/ui/app/controllers/clients/index.js b/ui/app/controllers/clients/index.js index ea4bd6d2cc76..d37c23e5f2c7 100644 --- a/ui/app/controllers/clients/index.js +++ b/ui/app/controllers/clients/index.js @@ -3,116 +3,120 @@ import Controller, { inject as controller } from '@ember/controller'; import { computed } from '@ember/object'; import { scheduleOnce } from '@ember/runloop'; import intersection from 'lodash.intersection'; -import Sortable from 'nomad-ui/mixins/sortable'; +import SortableFactory from 'nomad-ui/mixins/sortable-factory'; import Searchable from 'nomad-ui/mixins/searchable'; import { serialize, deserializedQueryParam as selection } from 'nomad-ui/utils/qp-serialize'; -export default Controller.extend(Sortable, Searchable, { - clientsController: controller('clients'), - - nodes: alias('model.nodes'), - agents: alias('model.agents'), - - queryParams: { - currentPage: 'page', - searchTerm: 'search', - sortProperty: 'sort', - sortDescending: 'desc', - qpClass: 'class', - qpState: 'state', - qpDatacenter: 'dc', - }, - - currentPage: 1, - pageSize: 8, - - sortProperty: 'modifyIndex', - sortDescending: true, - - searchProps: computed(() => ['id', 'name', 'datacenter']), - - qpClass: '', - qpState: '', - qpDatacenter: '', - - selectionClass: selection('qpClass'), - selectionState: selection('qpState'), - selectionDatacenter: selection('qpDatacenter'), - - optionsClass: computed('nodes.[]', function() { - const classes = Array.from(new Set(this.nodes.mapBy('nodeClass'))).compact(); - - // Remove any invalid node classes from the query param/selection - scheduleOnce('actions', () => { - this.set('qpClass', serialize(intersection(classes, this.selectionClass))); - }); - - return classes.sort().map(dc => ({ key: dc, label: dc })); - }), - - optionsState: computed(() => [ - { key: 'initializing', label: 'Initializing' }, - { key: 'ready', label: 'Ready' }, - { key: 'down', label: 'Down' }, - { key: 'ineligible', label: 'Ineligible' }, - { key: 'draining', label: 'Draining' }, - ]), - - optionsDatacenter: computed('nodes.[]', function() { - const datacenters = Array.from(new Set(this.nodes.mapBy('datacenter'))).compact(); - - // Remove any invalid datacenters from the query param/selection - scheduleOnce('actions', () => { - this.set('qpDatacenter', serialize(intersection(datacenters, this.selectionDatacenter))); - }); - - return datacenters.sort().map(dc => ({ key: dc, label: dc })); - }), - - filteredNodes: computed( - 'nodes.[]', - 'selectionClass', - 'selectionState', - 'selectionDatacenter', - function() { - const { - selectionClass: classes, - selectionState: states, - selectionDatacenter: datacenters, - } = this; - - const onlyIneligible = states.includes('ineligible'); - const onlyDraining = states.includes('draining'); - - // states is a composite of node status and other node states - const statuses = states.without('ineligible').without('draining'); - - return this.nodes.filter(node => { - if (classes.length && !classes.includes(node.get('nodeClass'))) return false; - if (statuses.length && !statuses.includes(node.get('status'))) return false; - if (datacenters.length && !datacenters.includes(node.get('datacenter'))) return false; - - if (onlyIneligible && node.get('isEligible')) return false; - if (onlyDraining && !node.get('isDraining')) return false; - - return true; +export default Controller.extend( + SortableFactory(['id', 'name', 'compositeStatus', 'datacenter']), + Searchable, + { + clientsController: controller('clients'), + + nodes: alias('model.nodes'), + agents: alias('model.agents'), + + queryParams: { + currentPage: 'page', + searchTerm: 'search', + sortProperty: 'sort', + sortDescending: 'desc', + qpClass: 'class', + qpState: 'state', + qpDatacenter: 'dc', + }, + + currentPage: 1, + pageSize: 8, + + sortProperty: 'modifyIndex', + sortDescending: true, + + searchProps: computed(() => ['id', 'name', 'datacenter']), + + qpClass: '', + qpState: '', + qpDatacenter: '', + + selectionClass: selection('qpClass'), + selectionState: selection('qpState'), + selectionDatacenter: selection('qpDatacenter'), + + optionsClass: computed('nodes.[]', function() { + const classes = Array.from(new Set(this.nodes.mapBy('nodeClass'))).compact(); + + // Remove any invalid node classes from the query param/selection + scheduleOnce('actions', () => { + this.set('qpClass', serialize(intersection(classes, this.selectionClass))); }); - } - ), - listToSort: alias('filteredNodes'), - listToSearch: alias('listSorted'), - sortedNodes: alias('listSearched'), + return classes.sort().map(dc => ({ key: dc, label: dc })); + }), + + optionsState: computed(() => [ + { key: 'initializing', label: 'Initializing' }, + { key: 'ready', label: 'Ready' }, + { key: 'down', label: 'Down' }, + { key: 'ineligible', label: 'Ineligible' }, + { key: 'draining', label: 'Draining' }, + ]), - isForbidden: alias('clientsController.isForbidden'), + optionsDatacenter: computed('nodes.[]', function() { + const datacenters = Array.from(new Set(this.nodes.mapBy('datacenter'))).compact(); + + // Remove any invalid datacenters from the query param/selection + scheduleOnce('actions', () => { + this.set('qpDatacenter', serialize(intersection(datacenters, this.selectionDatacenter))); + }); - setFacetQueryParam(queryParam, selection) { - this.set(queryParam, serialize(selection)); - }, + return datacenters.sort().map(dc => ({ key: dc, label: dc })); + }), + + filteredNodes: computed( + 'nodes.[]', + 'selectionClass', + 'selectionState', + 'selectionDatacenter', + function() { + const { + selectionClass: classes, + selectionState: states, + selectionDatacenter: datacenters, + } = this; + + const onlyIneligible = states.includes('ineligible'); + const onlyDraining = states.includes('draining'); + + // states is a composite of node status and other node states + const statuses = states.without('ineligible').without('draining'); + + return this.nodes.filter(node => { + if (classes.length && !classes.includes(node.get('nodeClass'))) return false; + if (statuses.length && !statuses.includes(node.get('status'))) return false; + if (datacenters.length && !datacenters.includes(node.get('datacenter'))) return false; + + if (onlyIneligible && node.get('isEligible')) return false; + if (onlyDraining && !node.get('isDraining')) return false; + + return true; + }); + } + ), + + listToSort: alias('filteredNodes'), + listToSearch: alias('listSorted'), + sortedNodes: alias('listSearched'), + + isForbidden: alias('clientsController.isForbidden'), + + setFacetQueryParam(queryParam, selection) { + this.set(queryParam, serialize(selection)); + }, - actions: { - gotoNode(node) { - this.transitionToRoute('clients.client', node); + actions: { + gotoNode(node) { + this.transitionToRoute('clients.client', node); + }, }, - }, -}); + } +); diff --git a/ui/app/mixins/sortable-factory.js b/ui/app/mixins/sortable-factory.js new file mode 100644 index 000000000000..e65d18f9bc19 --- /dev/null +++ b/ui/app/mixins/sortable-factory.js @@ -0,0 +1,58 @@ +import Mixin from '@ember/object/mixin'; +import Ember from 'ember'; +import { computed } from '@ember/object'; +import { warn } from '@ember/debug'; + +/** + Sortable mixin factory + + Simple sorting behavior for a list of objects. Pass the list of properties + you want the list to be live-sorted based on, or use the generic sortable.js + if you don’t need that. + + Properties to override: + - sortProperty: the property to sort by + - sortDescending: when true, the list is reversed + - listToSort: the list of objects to sort + + Properties provided: + - listSorted: a copy of listToSort that has been sorted +*/ +export default function sortableFactory(properties, fromSortableMixin) { + const eachProperties = properties.map(property => `listToSort.@each.${property}`); + + return Mixin.create({ + // Override in mixin consumer + sortProperty: null, + sortDescending: true, + listToSort: computed(() => []), + + _sortableFactoryWarningPrinted: false, + + listSorted: computed( + ...eachProperties, + 'listToSort.[]', + 'sortProperty', + 'sortDescending', + function() { + if (!this._sortableFactoryWarningPrinted && !Ember.testing) { + let message = + 'Using SortableFactory without property keys means the list will only sort when the members change, not when any of their properties change.'; + + if (fromSortableMixin) { + message += ' The Sortable mixin is deprecated in favor of SortableFactory.'; + } + + warn(message, properties.length > 0, { id: 'nomad.no-sortable-properties' }); + this.set('_sortableFactoryWarningPrinted', true); + } + + const sorted = this.listToSort.compact().sortBy(this.sortProperty); + if (this.sortDescending) { + return sorted.reverse(); + } + return sorted; + } + ), + }); +} diff --git a/ui/app/mixins/sortable.js b/ui/app/mixins/sortable.js index 7a86d3c80d5b..95ebe0cb25a0 100644 --- a/ui/app/mixins/sortable.js +++ b/ui/app/mixins/sortable.js @@ -1,32 +1,5 @@ -import Mixin from '@ember/object/mixin'; -import { computed } from '@ember/object'; +import SortableFactory from 'nomad-ui/mixins/sortable-factory'; -/** - Sortable mixin +// A generic version of SortableFactory with no sort property dependent keys. - Simple sorting behavior for a list of objects. - - Properties to override: - - sortProperty: the property to sort by - - sortDescending: when true, the list is reversed - - listToSort: the list of objects to sort - - Properties provided: - - listSorted: a copy of listToSort that has been sorted -*/ -export default Mixin.create({ - // Override in mixin consumer - sortProperty: null, - sortDescending: true, - listToSort: computed(() => []), - - listSorted: computed('listToSort.[]', 'sortProperty', 'sortDescending', function() { - const sorted = this.listToSort - .compact() - .sortBy(this.sortProperty); - if (this.sortDescending) { - return sorted.reverse(); - } - return sorted; - }), -}); +export default SortableFactory([], true); diff --git a/ui/app/models/node.js b/ui/app/models/node.js index 412548d3624b..c3efaff543bb 100644 --- a/ui/app/models/node.js +++ b/ui/app/models/node.js @@ -61,7 +61,13 @@ export default Model.extend({ // A status attribute that includes states not included in node status. // Useful for coloring and sorting nodes - compositeStatus: computed('status', 'isEligible', function() { - return this.isEligible ? this.status : 'ineligible'; + compositeStatus: computed('isDraining', 'isEligible', 'status', function() { + if (this.isDraining) { + return 'draining'; + } else if (!this.isEligible) { + return 'ineligible'; + } else { + return this.status; + } }), }); diff --git a/ui/app/styles/components/node-status-light.scss b/ui/app/styles/components/node-status-light.scss index 9077e333a8f0..50153ce2f7f7 100644 --- a/ui/app/styles/components/node-status-light.scss +++ b/ui/app/styles/components/node-status-light.scss @@ -27,7 +27,8 @@ $size: 0.75em; ); } - &.ineligible { + &.ineligible, + &.draining { background: $warning; } } diff --git a/ui/app/templates/clients/index.hbs b/ui/app/templates/clients/index.hbs index e7245c1bc943..f29105490dd7 100644 --- a/ui/app/templates/clients/index.hbs +++ b/ui/app/templates/clients/index.hbs @@ -49,7 +49,7 @@