diff --git a/ui/.prettierrc b/ui/.prettierrc index 7f9eaa64f7f..546369158eb 100644 --- a/ui/.prettierrc +++ b/ui/.prettierrc @@ -1,3 +1,13 @@ -printWidth: 100 -singleQuote: true -trailingComma: es5 +{ + "printWidth": 100, + "singleQuote": true, + "trailingComma": "es5", + "overrides": [ + { + "files": "*.hbs", + "options": { + "singleQuote": false + } + } + ] +} diff --git a/ui/app/components/allocation-row.js b/ui/app/components/allocation-row.js index 0a9fb1c0a0a..a92c618adae 100644 --- a/ui/app/components/allocation-row.js +++ b/ui/app/components/allocation-row.js @@ -65,7 +65,9 @@ export default class AllocationRow extends Component { do { if (this.stats) { try { - yield this.get('stats.poll').perform(); + yield this.get('stats.poll') + .linked() + .perform(); this.set('statsError', false); } catch (error) { this.set('statsError', true); diff --git a/ui/app/components/distribution-bar.js b/ui/app/components/distribution-bar.js index ee9af8456d6..50cd5b5c664 100644 --- a/ui/app/components/distribution-bar.js +++ b/ui/app/components/distribution-bar.js @@ -22,6 +22,7 @@ const sumAggregate = (total, val) => total + val; export default class DistributionBar extends Component.extend(WindowResizable) { chart = null; @overridable(() => null) data; + onSliceClick = null; activeDatum = null; isNarrow = false; @@ -33,11 +34,13 @@ export default class DistributionBar extends Component.extend(WindowResizable) { const data = copy(this.data, true); const sum = data.mapBy('value').reduce(sumAggregate, 0); - return data.map(({ label, value, className, layers }, index) => ({ + return data.map(({ label, value, className, layers, legendLink, help }, index) => ({ label, value, className, layers, + legendLink, + help, index, percent: value / sum, offset: @@ -121,8 +124,14 @@ export default class DistributionBar extends Component.extend(WindowResizable) { const activeDatum = this.activeDatum; const isActive = activeDatum && activeDatum.label === d.label; const isInactive = activeDatum && activeDatum.label !== d.label; - return [ className, isActive && 'active', isInactive && 'inactive' ].compact().join(' '); - }); + const isClickable = !!this.onSliceClick; + return [ + className, + isActive && 'active', + isInactive && 'inactive', + isClickable && 'clickable' + ].compact().join(' '); + }).attr('data-test-slice-label', d => d.className); this.set('slices', slices); @@ -172,6 +181,10 @@ export default class DistributionBar extends Component.extend(WindowResizable) { .attr('height', '6px') .attr('y', '50%'); } + + if (this.onSliceClick) { + slices.on('click', this.onSliceClick); + } } /* eslint-enable */ diff --git a/ui/app/components/job-client-status-bar.js b/ui/app/components/job-client-status-bar.js new file mode 100644 index 00000000000..9f3690b01f2 --- /dev/null +++ b/ui/app/components/job-client-status-bar.js @@ -0,0 +1,120 @@ +import { computed } from '@ember/object'; +import DistributionBar from './distribution-bar'; +import classic from 'ember-classic-decorator'; + +@classic +export default class JobClientStatusBar extends DistributionBar { + layoutName = 'components/distribution-bar'; + + 'data-test-job-client-status-bar' = true; + job = null; + jobClientStatus = null; + + @computed('job.namespace', 'jobClientStatus.byStatus') + get data() { + const { + queued, + starting, + running, + complete, + degraded, + failed, + lost, + notScheduled, + } = this.jobClientStatus.byStatus; + + return [ + { + label: 'Queued', + value: queued.length, + className: 'queued', + legendLink: { + queryParams: { + status: JSON.stringify(['queued']), + namespace: this.job.namespace.get('id'), + }, + }, + }, + { + label: 'Starting', + value: starting.length, + className: 'starting', + legendLink: { + queryParams: { + status: JSON.stringify(['starting']), + namespace: this.job.namespace.get('id'), + }, + }, + layers: 2, + }, + { + label: 'Running', + value: running.length, + className: 'running', + legendLink: { + queryParams: { + status: JSON.stringify(['running']), + namespace: this.job.namespace.get('id'), + }, + }, + }, + { + label: 'Complete', + value: complete.length, + className: 'complete', + legendLink: { + queryParams: { + status: JSON.stringify(['complete']), + namespace: this.job.namespace.get('id'), + }, + }, + }, + { + label: 'Degraded', + value: degraded.length, + className: 'degraded', + legendLink: { + queryParams: { + status: JSON.stringify(['degraded']), + namespace: this.job.namespace.get('id'), + }, + }, + help: 'Some allocations for this job were not successfull or did not run.', + }, + { + label: 'Failed', + value: failed.length, + className: 'failed', + legendLink: { + queryParams: { + status: JSON.stringify(['failed']), + namespace: this.job.namespace.get('id'), + }, + }, + }, + { + label: 'Lost', + value: lost.length, + className: 'lost', + legendLink: { + queryParams: { + status: JSON.stringify(['lost']), + namespace: this.job.namespace.get('id'), + }, + }, + }, + { + label: 'Not Scheduled', + value: notScheduled.length, + className: 'not-scheduled', + legendLink: { + queryParams: { + status: JSON.stringify(['notScheduled']), + namespace: this.job.namespace.get('id'), + }, + }, + help: 'No allocations for this job were scheduled into these clients.', + }, + ]; + } +} diff --git a/ui/app/components/job-client-status-row.js b/ui/app/components/job-client-status-row.js new file mode 100644 index 00000000000..dc467fbf5d8 --- /dev/null +++ b/ui/app/components/job-client-status-row.js @@ -0,0 +1,89 @@ +import EmberObject from '@ember/object'; +import Component from '@glimmer/component'; + +export default class ClientRow extends Component { + // Attribute set in the template as @onClick. + onClick() {} + + get row() { + return this.args.row.model; + } + + get shouldDisplayAllocationSummary() { + return this.args.row.model.jobStatus !== 'notScheduled'; + } + + get allocationSummaryPlaceholder() { + switch (this.args.row.model.jobStatus) { + case 'notScheduled': + return 'Not Scheduled'; + default: + return ''; + } + } + + get humanizedJobStatus() { + switch (this.args.row.model.jobStatus) { + case 'notScheduled': + return 'not scheduled'; + default: + return this.args.row.model.jobStatus; + } + } + + get jobStatusClass() { + switch (this.args.row.model.jobStatus) { + case 'notScheduled': + return 'not-scheduled'; + default: + return this.args.row.model.jobStatus; + } + } + + get allocationContainer() { + const statusSummary = { + queuedAllocs: 0, + completeAllocs: 0, + failedAllocs: 0, + runningAllocs: 0, + startingAllocs: 0, + lostAllocs: 0, + }; + + switch (this.args.row.model.jobStatus) { + case 'notSchedule': + break; + case 'queued': + statusSummary.queuedAllocs = this.args.row.model.allocations.length; + break; + case 'starting': + statusSummary.startingAllocs = this.args.row.model.allocations.length; + break; + default: + for (const alloc of this.args.row.model.allocations) { + switch (alloc.clientStatus) { + case 'running': + statusSummary.runningAllocs++; + break; + case 'lost': + statusSummary.lostAllocs++; + break; + case 'failed': + statusSummary.failedAllocs++; + break; + case 'complete': + statusSummary.completeAllocs++; + break; + case 'starting': + statusSummary.startingAllocs++; + break; + } + } + } + + const Allocations = EmberObject.extend({ + ...statusSummary, + }); + return Allocations.create(); + } +} diff --git a/ui/app/components/job-page/parameterized-child.js b/ui/app/components/job-page/parameterized-child.js index 3f941067c06..daf0e418340 100644 --- a/ui/app/components/job-page/parameterized-child.js +++ b/ui/app/components/job-page/parameterized-child.js @@ -1,11 +1,14 @@ import { computed } from '@ember/object'; import { alias } from '@ember/object/computed'; +import { inject as service } from '@ember/service'; import PeriodicChildJobPage from './periodic-child'; import classic from 'ember-classic-decorator'; +import jobClientStatus from 'nomad-ui/utils/properties/job-client-status'; @classic export default class ParameterizedChild extends PeriodicChildJobPage { @alias('job.decodedPayload') payload; + @service store; @computed('payload') get payloadJSON() { @@ -17,4 +20,10 @@ export default class ParameterizedChild extends PeriodicChildJobPage { } return json; } + + @jobClientStatus('nodes', 'job') jobClientStatus; + + get nodes() { + return this.store.peekAll('node'); + } } diff --git a/ui/app/components/job-page/parts/job-client-status-summary.js b/ui/app/components/job-page/parts/job-client-status-summary.js new file mode 100644 index 00000000000..209d5258b9e --- /dev/null +++ b/ui/app/components/job-page/parts/job-client-status-summary.js @@ -0,0 +1,28 @@ +import Component from '@ember/component'; +import { action, computed } from '@ember/object'; +import { classNames } from '@ember-decorators/component'; +import classic from 'ember-classic-decorator'; + +@classic +@classNames('boxed-section') +export default class JobClientStatusSummary extends Component { + job = null; + jobClientStatus = null; + gotoClients() {} + + @computed + get isExpanded() { + const storageValue = window.localStorage.nomadExpandJobClientStatusSummary; + return storageValue != null ? JSON.parse(storageValue) : true; + } + + @action + onSliceClick(slice) { + this.gotoClients([slice.className.camelize()]); + } + + persist(item, isOpen) { + window.localStorage.nomadExpandJobClientStatusSummary = isOpen; + this.notifyPropertyChange('isExpanded'); + } +} diff --git a/ui/app/components/job-page/parts/summary.js b/ui/app/components/job-page/parts/summary.js index 87ca0a4c38a..0cb821cc834 100644 --- a/ui/app/components/job-page/parts/summary.js +++ b/ui/app/components/job-page/parts/summary.js @@ -7,9 +7,12 @@ import classic from 'ember-classic-decorator'; @classNames('boxed-section') export default class Summary extends Component { job = null; + forceCollapsed = false; - @computed + @computed('forceCollapsed') get isExpanded() { + if (this.forceCollapsed) return false; + const storageValue = window.localStorage.nomadExpandJobSummary; return storageValue != null ? JSON.parse(storageValue) : true; } diff --git a/ui/app/components/job-page/periodic-child.js b/ui/app/components/job-page/periodic-child.js index dfe42225dc3..d581d88dc29 100644 --- a/ui/app/components/job-page/periodic-child.js +++ b/ui/app/components/job-page/periodic-child.js @@ -1,9 +1,13 @@ import AbstractJobPage from './abstract'; import { computed } from '@ember/object'; +import { inject as service } from '@ember/service'; import classic from 'ember-classic-decorator'; +import jobClientStatus from 'nomad-ui/utils/properties/job-client-status'; @classic export default class PeriodicChild extends AbstractJobPage { + @service store; + @computed('job.{name,id}', 'job.parent.{name,id}') get breadcrumbs() { const job = this.job; @@ -21,4 +25,10 @@ export default class PeriodicChild extends AbstractJobPage { }, ]; } + + @jobClientStatus('nodes', 'job') jobClientStatus; + + get nodes() { + return this.store.peekAll('node'); + } } diff --git a/ui/app/components/job-page/sysbatch.js b/ui/app/components/job-page/sysbatch.js new file mode 100644 index 00000000000..0819ed49426 --- /dev/null +++ b/ui/app/components/job-page/sysbatch.js @@ -0,0 +1,15 @@ +import AbstractJobPage from './abstract'; +import classic from 'ember-classic-decorator'; +import { inject as service } from '@ember/service'; +import jobClientStatus from 'nomad-ui/utils/properties/job-client-status'; + +@classic +export default class Sysbatch extends AbstractJobPage { + @service store; + + @jobClientStatus('nodes', 'job') jobClientStatus; + + get nodes() { + return this.store.peekAll('node'); + } +} diff --git a/ui/app/components/job-page/system.js b/ui/app/components/job-page/system.js index bf2c0444246..5909c8f163d 100644 --- a/ui/app/components/job-page/system.js +++ b/ui/app/components/job-page/system.js @@ -1,5 +1,15 @@ import AbstractJobPage from './abstract'; import classic from 'ember-classic-decorator'; +import { inject as service } from '@ember/service'; +import jobClientStatus from 'nomad-ui/utils/properties/job-client-status'; @classic -export default class System extends AbstractJobPage {} +export default class System extends AbstractJobPage { + @service store; + + @jobClientStatus('nodes', 'job') jobClientStatus; + + get nodes() { + return this.store.peekAll('node'); + } +} diff --git a/ui/app/components/task-row.js b/ui/app/components/task-row.js index cde652fa7ab..e47ff85883d 100644 --- a/ui/app/components/task-row.js +++ b/ui/app/components/task-row.js @@ -54,7 +54,9 @@ export default class TaskRow extends Component { do { if (this.stats) { try { - yield this.get('stats.poll').perform(); + yield this.get('stats.poll') + .linked() + .perform(); this.set('statsError', false); } catch (error) { this.set('statsError', true); diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index 12c05fbd1f9..4dcf47d6227 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -83,6 +83,7 @@ export default class IndexController extends Controller.extend(Sortable, Searcha { key: 'periodic', label: 'Periodic' }, { key: 'service', label: 'Service' }, { key: 'system', label: 'System' }, + { key: 'sysbatch', label: 'System Batch' }, ]; } diff --git a/ui/app/controllers/jobs/job/clients.js b/ui/app/controllers/jobs/job/clients.js new file mode 100644 index 00000000000..2e14798bf56 --- /dev/null +++ b/ui/app/controllers/jobs/job/clients.js @@ -0,0 +1,192 @@ +/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */ +import Controller from '@ember/controller'; +import { action, computed } from '@ember/object'; +import { scheduleOnce } from '@ember/runloop'; +import intersection from 'lodash.intersection'; +import { alias } from '@ember/object/computed'; +import SortableFactory from 'nomad-ui/mixins/sortable-factory'; +import Searchable from 'nomad-ui/mixins/searchable'; +import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; +import jobClientStatus from 'nomad-ui/utils/properties/job-client-status'; +import { serialize, deserializedQueryParam as selection } from 'nomad-ui/utils/qp-serialize'; +import classic from 'ember-classic-decorator'; + +@classic +export default class ClientsController extends Controller.extend( + SortableFactory(['id', 'name', 'jobStatus']), + Searchable, + WithNamespaceResetting + ) { + queryParams = [ + { + currentPage: 'page', + }, + { + searchTerm: 'search', + }, + { + qpStatus: 'status', + }, + { + qpDatacenter: 'dc', + }, + { + qpClientClass: 'clientclass', + }, + { + sortProperty: 'sort', + }, + { + sortDescending: 'desc', + }, + ]; + + qpStatus = ''; + qpDatacenter = ''; + qpClientClass = ''; + + currentPage = 1; + pageSize = 25; + + sortProperty = 'jobStatus'; + sortDescending = false; + + @selection('qpStatus') selectionStatus; + @selection('qpDatacenter') selectionDatacenter; + @selection('qpClientClass') selectionClientClass; + + @alias('model') job; + @jobClientStatus('allNodes', 'job') jobClientStatus; + + @alias('filteredNodes') listToSort; + @alias('listSorted') listToSearch; + @alias('listSearched') sortedClients; + + @computed('store') + get allNodes() { + return this.store.peekAll('node').length + ? this.store.peekAll('node') + : this.store.findAll('node'); + } + + @computed('allNodes', 'jobClientStatus.byNode') + get nodes() { + return this.allNodes.filter(node => this.jobClientStatus.byNode[node.id]); + } + + @computed + get searchProps() { + return ['node.id', 'node.name']; + } + + @computed( + 'nodes', + 'job.allocations', + 'jobClientStatus.byNode', + 'selectionStatus', + 'selectionDatacenter', + 'selectionClientClass' + ) + get filteredNodes() { + const { + selectionStatus: statuses, + selectionDatacenter: datacenters, + selectionClientClass: clientClasses, + } = this; + + return this.nodes + .filter(node => { + if (statuses.length && !statuses.includes(this.jobClientStatus.byNode[node.id])) { + return false; + } + if (datacenters.length && !datacenters.includes(node.datacenter)) { + return false; + } + if (clientClasses.length && !clientClasses.includes(node.nodeClass)) { + return false; + } + + return true; + }) + .map(node => { + const allocations = this.job.allocations.filter(alloc => alloc.get('node.id') == node.id); + + return { + node, + jobStatus: this.jobClientStatus.byNode[node.id], + allocations, + createTime: eldestCreateTime(allocations), + modifyTime: mostRecentModifyTime(allocations), + }; + }); + } + + @computed + get optionsJobStatus() { + return [ + { key: 'queued', label: 'Queued' }, + { key: 'notScheduled', label: 'Not Scheduled' }, + { key: 'starting', label: 'Starting' }, + { key: 'running', label: 'Running' }, + { key: 'complete', label: 'Complete' }, + { key: 'degraded', label: 'Degraded' }, + { key: 'failed', label: 'Failed' }, + { key: 'lost', label: 'Lost' }, + ]; + } + + @computed('selectionDatacenter', 'nodes') + get optionsDatacenter() { + const datacenters = Array.from(new Set(this.nodes.mapBy('datacenter'))).compact(); + + // Update query param when the list of datacenters changes. + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.set('qpDatacenter', serialize(intersection(datacenters, this.selectionDatacenter))); + }); + + return datacenters.sort().map(dc => ({ key: dc, label: dc })); + } + + @computed('selectionClientClass', 'nodes') + get optionsClientClass() { + const clientClasses = Array.from(new Set(this.nodes.mapBy('nodeClass'))).compact(); + + // Update query param when the list of datacenters changes. + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.set('qpClientClass', serialize(intersection(clientClasses, this.selectionClientClass))); + }); + + return clientClasses.sort().map(clientClass => ({ key: clientClass, label: clientClass })); + } + + @action + gotoClient(client) { + this.transitionToRoute('clients.client', client); + } + + setFacetQueryParam(queryParam, selection) { + this.set(queryParam, serialize(selection)); + } +} + +function eldestCreateTime(allocations) { + let eldest = null; + for (const alloc of allocations) { + if (!eldest || alloc.createTime < eldest) { + eldest = alloc.createTime; + } + } + return eldest; +} + +function mostRecentModifyTime(allocations) { + let mostRecent = null; + for (const alloc of allocations) { + if (!mostRecent || alloc.modifyTime > mostRecent) { + mostRecent = alloc.modifyTime; + } + } + return mostRecent; +} diff --git a/ui/app/controllers/jobs/job/index.js b/ui/app/controllers/jobs/job/index.js index eae91858f7a..2b663959f97 100644 --- a/ui/app/controllers/jobs/job/index.js +++ b/ui/app/controllers/jobs/job/index.js @@ -39,4 +39,14 @@ export default class IndexController extends Controller.extend(WithNamespaceRese queryParams: { jobNamespace: job.get('namespace.name') }, }); } + + @action + gotoClients(statusFilter) { + this.transitionToRoute('jobs.job.clients', this.job, { + queryParams: { + status: JSON.stringify(statusFilter), + namespace: this.job.get('namespace.name'), + }, + }); + } } diff --git a/ui/app/models/job.js b/ui/app/models/job.js index 79323d280ae..8182546dfde 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -7,7 +7,7 @@ import RSVP from 'rsvp'; import { assert } from '@ember/debug'; import classic from 'ember-classic-decorator'; -const JOB_TYPES = ['service', 'batch', 'system']; +const JOB_TYPES = ['service', 'batch', 'system', 'sysbatch']; @classic export default class Job extends Model { @@ -39,6 +39,11 @@ export default class Job extends Model { return this.periodic || (this.parameterized && !this.dispatched); } + @computed('type') + get hasClientStatus() { + return this.type === 'system' || this.type === 'sysbatch'; + } + @belongsTo('job', { inverse: 'children' }) parent; @hasMany('job', { inverse: 'parent' }) children; diff --git a/ui/app/router.js b/ui/app/router.js index 072291fe673..8e5c2070895 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -23,6 +23,7 @@ Router.map(function() { this.route('dispatch'); this.route('evaluations'); this.route('allocations'); + this.route('clients'); }); }); diff --git a/ui/app/routes/jobs/job/clients.js b/ui/app/routes/jobs/job/clients.js new file mode 100644 index 00000000000..71b9d23e869 --- /dev/null +++ b/ui/app/routes/jobs/job/clients.js @@ -0,0 +1,30 @@ +import Route from '@ember/routing/route'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; +import { watchRecord, watchRelationship, watchAll } from 'nomad-ui/utils/properties/watch'; +import { collect } from '@ember/object/computed'; + +export default class ClientsRoute extends Route.extend(WithWatchers) { + async model() { + await this.store.findAll('node'); + return this.modelFor('jobs.job'); + } + + startWatchers(controller, model) { + if (!model) { + return; + } + + controller.set('watchers', { + model: this.watch.perform(model), + allocations: this.watchAllocations.perform(model), + nodes: this.watchNodes.perform(), + }); + } + + @watchRecord('job') watch; + @watchAll('node') watchNodes; + @watchRelationship('allocations') watchAllocations; + + @collect('watch', 'watchNodes', 'watchAllocations') + watchers; +} diff --git a/ui/app/routes/jobs/job/index.js b/ui/app/routes/jobs/job/index.js index 8f1cf1827fe..463edee0101 100644 --- a/ui/app/routes/jobs/job/index.js +++ b/ui/app/routes/jobs/job/index.js @@ -1,9 +1,20 @@ import Route from '@ember/routing/route'; import { collect } from '@ember/object/computed'; -import { watchRecord, watchRelationship, watchQuery } from 'nomad-ui/utils/properties/watch'; +import { + watchRecord, + watchRelationship, + watchAll, + watchQuery, +} from 'nomad-ui/utils/properties/watch'; import WithWatchers from 'nomad-ui/mixins/with-watchers'; export default class IndexRoute extends Route.extend(WithWatchers) { + async model() { + // Optimizing future node look ups by preemptively loading everything + await this.store.findAll('node'); + return this.modelFor('jobs.job'); + } + startWatchers(controller, model) { if (!model) { return; @@ -18,6 +29,7 @@ export default class IndexRoute extends Route.extend(WithWatchers) { list: model.get('hasChildren') && this.watchAllJobs.perform({ namespace: model.namespace.get('name') }), + nodes: model.get('hasClientStatus') && this.watchNodes.perform(), }); } @@ -35,6 +47,7 @@ export default class IndexRoute extends Route.extend(WithWatchers) { @watchRecord('job') watch; @watchQuery('job') watchAllJobs; + @watchAll('node') watchNodes; @watchRecord('job-summary') watchSummary; @watchRelationship('allocations') watchAllocations; @watchRelationship('evaluations') watchEvaluations; @@ -46,7 +59,8 @@ export default class IndexRoute extends Route.extend(WithWatchers) { 'watchSummary', 'watchAllocations', 'watchEvaluations', - 'watchLatestDeployment' + 'watchLatestDeployment', + 'watchNodes' ) watchers; } diff --git a/ui/app/styles/charts/colors.scss b/ui/app/styles/charts/colors.scss index 50f370607f6..1676fdc8937 100644 --- a/ui/app/styles/charts/colors.scss +++ b/ui/app/styles/charts/colors.scss @@ -4,6 +4,8 @@ $running: $primary; $complete: $nomad-green-dark; $failed: $danger; $lost: $dark; +$not-scheduled: $blue-200; +$degraded: $warning; .chart { .queued { @@ -37,6 +39,14 @@ $lost: $dark; .lost { fill: $lost; } + + .not-scheduled { + fill: $not-scheduled; + } + + .degraded { + fill: $degraded; + } } .color-swatch { @@ -102,6 +112,14 @@ $lost: $dark; background: $lost; } + &.not-scheduled { + background: $not-scheduled; + } + + &.degraded { + background: $degraded; + } + @each $name, $pair in $colors { $color: nth($pair, 1); diff --git a/ui/app/styles/charts/distribution-bar.scss b/ui/app/styles/charts/distribution-bar.scss index fcd4a782425..507f8bdb93f 100644 --- a/ui/app/styles/charts/distribution-bar.scss +++ b/ui/app/styles/charts/distribution-bar.scss @@ -13,6 +13,10 @@ opacity: 1; } + .clickable { + cursor: pointer; + } + .inactive { opacity: 0.2; } @@ -63,16 +67,48 @@ // Ensure two columns, but don't use the full width width: 35%; - .label, - .value { - display: inline; - font-weight: $weight-normal; + .legend-item { + display: flex; + align-items: center; + + .color-swatch { + margin-right: 0.5rem; + } + + .text { + flex-grow: 1; + + .label, + .value { + display: inline; + font-weight: $weight-normal; + } + } + + .icon { + width: 1.2rem; + height: 1.2rem; + } } &.is-active { background-color: rgba($info, 0.1); } + &.is-clickable { + a { + display: block; + text-decoration: none; + color: inherit; + } + + &:not(.is-empty) { + &:hover { + background-color: rgba($info, 0.1); + } + } + } + &.is-empty { color: darken($grey-blue, 20%); border: none; diff --git a/ui/app/styles/components/accordion.scss b/ui/app/styles/components/accordion.scss index 2b59864a2cd..7f21d5d83db 100644 --- a/ui/app/styles/components/accordion.scss +++ b/ui/app/styles/components/accordion.scss @@ -33,6 +33,11 @@ .accordion-head-content { width: 100%; margin-right: 1.5em; + + .tooltip { + margin-left: 0.5rem; + margin-right: 0.5rem; + } } .accordion-toggle { diff --git a/ui/app/templates/components/job-client-status-row.hbs b/ui/app/templates/components/job-client-status-row.hbs new file mode 100644 index 00000000000..08540a00c27 --- /dev/null +++ b/ui/app/templates/components/job-client-status-row.hbs @@ -0,0 +1,41 @@ + + + + {{this.row.node.shortId}} + + + + {{this.row.node.name}} + + + {{#if this.row.createTime}} + + {{moment-from-now this.row.createTime}} + + {{else}} + - + {{/if}} + + + {{#if this.row.modifyTime}} + + {{moment-from-now this.row.modifyTime}} + + {{else}} + - + {{/if}} + + + + {{this.humanizedJobStatus}} + + + {{#if this.shouldDisplayAllocationSummary}} +
+ +
+ {{else}} +
{{this.allocationSummaryPlaceholder}}
+ {{/if}} + + \ No newline at end of file diff --git a/ui/app/templates/components/job-page/parameterized-child.hbs b/ui/app/templates/components/job-page/parameterized-child.hbs index 881c9af49ae..5a4777057c8 100644 --- a/ui/app/templates/components/job-page/parameterized-child.hbs +++ b/ui/app/templates/components/job-page/parameterized-child.hbs @@ -19,7 +19,15 @@ - + {{#if this.job.hasClientStatus}} + + {{/if}} + + @@ -62,4 +70,4 @@ {{/if}} - + \ No newline at end of file diff --git a/ui/app/templates/components/job-page/parts/job-client-status-summary.hbs b/ui/app/templates/components/job-page/parts/job-client-status-summary.hbs new file mode 100644 index 00000000000..837a6c34a0f --- /dev/null +++ b/ui/app/templates/components/job-page/parts/job-client-status-summary.hbs @@ -0,0 +1,55 @@ + + +
+
+ Job Status in Client + + {{this.jobClientStatus.totalNodes}} + + + {{x-icon "info-circle-outline" class="is-faded"}} + +
+ {{#unless a.isOpen}} +
+
+ +
+
+ {{/unless}} +
+
+ + +
    + {{#each chart.data as |datum index|}} +
  1. + {{#if (gt datum.value 0)}} + + + + {{else}} + + {{/if}} +
  2. + {{/each}} +
+
+
+
diff --git a/ui/app/templates/components/job-page/parts/summary-legend-item.hbs b/ui/app/templates/components/job-page/parts/summary-legend-item.hbs new file mode 100644 index 00000000000..ab7997fbe6c --- /dev/null +++ b/ui/app/templates/components/job-page/parts/summary-legend-item.hbs @@ -0,0 +1,12 @@ +
+ + + {{@datum.value}} + {{@datum.label}} + + {{#if @datum.help}} + + {{x-icon "info-circle-outline" class="is-faded"}} + + {{/if}} +
diff --git a/ui/app/templates/components/job-page/parts/summary.hbs b/ui/app/templates/components/job-page/parts/summary.hbs index e90cdff5375..244e4f7d329 100644 --- a/ui/app/templates/components/job-page/parts/summary.hbs +++ b/ui/app/templates/components/job-page/parts/summary.hbs @@ -40,15 +40,10 @@
    {{#each chart.data as |datum index|}}
  1. - - {{datum.value}} - - {{datum.label}} - +
  2. {{/each}}
{{/component}} - diff --git a/ui/app/templates/components/job-page/periodic-child.hbs b/ui/app/templates/components/job-page/periodic-child.hbs index 3c14a25fc26..965c5a41a76 100644 --- a/ui/app/templates/components/job-page/periodic-child.hbs +++ b/ui/app/templates/components/job-page/periodic-child.hbs @@ -19,7 +19,15 @@ - + {{#if this.job.hasClientStatus}} + + {{/if}} + + @@ -30,4 +38,4 @@ @gotoTaskGroup={{this.gotoTaskGroup}} /> - + \ No newline at end of file diff --git a/ui/app/templates/components/job-page/sysbatch.hbs b/ui/app/templates/components/job-page/sysbatch.hbs new file mode 100644 index 00000000000..35f6d6faa3a --- /dev/null +++ b/ui/app/templates/components/job-page/sysbatch.hbs @@ -0,0 +1,32 @@ + + + + + +
+
+ Type:{{this.job.type}} | + Priority:{{this.job.priority}} + {{#if (and this.job.namespace this.system.shouldShowNamespaces)}} + | Namespace:{{this.job.namespace.name}} + {{/if}} +
+
+ + + + + + + + + + +
\ No newline at end of file diff --git a/ui/app/templates/components/job-page/system.hbs b/ui/app/templates/components/job-page/system.hbs index 5e0abbea25f..1f1db67d186 100644 --- a/ui/app/templates/components/job-page/system.hbs +++ b/ui/app/templates/components/job-page/system.hbs @@ -19,7 +19,12 @@ {{/each}} {{/if}} - + + + diff --git a/ui/app/templates/components/job-subnav.hbs b/ui/app/templates/components/job-subnav.hbs index a655756d862..67d9974dbc5 100644 --- a/ui/app/templates/components/job-subnav.hbs +++ b/ui/app/templates/components/job-subnav.hbs @@ -1,12 +1,15 @@
    -
  • Overview
  • -
  • Definition
  • -
  • Versions
  • +
  • Overview
  • +
  • Definition
  • +
  • Versions
  • {{#if this.job.supportsDeployments}} -
  • Deployments
  • +
  • Deployments
  • + {{/if}} +
  • Allocations
  • +
  • Evaluations
  • + {{#if (and this.job.hasClientStatus (not this.job.hasChildren))}} +
  • Clients
  • {{/if}} -
  • Allocations
  • -
  • Evaluations
diff --git a/ui/app/templates/jobs/job/clients.hbs b/ui/app/templates/jobs/job/clients.hbs new file mode 100644 index 00000000000..b61d6dacfe8 --- /dev/null +++ b/ui/app/templates/jobs/job/clients.hbs @@ -0,0 +1,106 @@ +{{page-title "Job " this.job.name " clients"}} + +
+ {{#if this.nodes.length}} +
+
+ +
+
+
+ + + +
+
+
+ {{#if this.sortedClients}} + + + + Client ID + Client Name + Created + Modified + Job Status + Allocation Summary + + + + + +
+ +
+
+ {{else}} +
+
+

+ No Matches +

+

+ No clients match the term + + {{this.searchTerm}} + +

+
+
+ {{/if}} + {{else}} +
+
+

+ No Clients +

+

+ No clients available. +

+
+
+ {{/if}} +
\ No newline at end of file diff --git a/ui/app/templates/jobs/job/index.hbs b/ui/app/templates/jobs/job/index.hbs index 56d18ec2e57..41834690f89 100644 --- a/ui/app/templates/jobs/job/index.hbs +++ b/ui/app/templates/jobs/job/index.hbs @@ -5,4 +5,6 @@ sortDescending=this.sortDescending currentPage=this.currentPage gotoJob=(action "gotoJob") - gotoTaskGroup=(action "gotoTaskGroup")}} + gotoTaskGroup=(action "gotoTaskGroup") + gotoClients=(action "gotoClients") +}} \ No newline at end of file diff --git a/ui/app/utils/properties/job-client-status.js b/ui/app/utils/properties/job-client-status.js new file mode 100644 index 00000000000..c6482076c06 --- /dev/null +++ b/ui/app/utils/properties/job-client-status.js @@ -0,0 +1,141 @@ +import { computed } from '@ember/object'; + +const STATUS = [ + 'queued', + 'notScheduled', + 'starting', + 'running', + 'complete', + 'degraded', + 'failed', + 'lost', +]; + +// An Ember.Computed property that computes the aggregated status of a job in a +// client based on the desiredStatus of each allocation placed in the client. +// +// ex. clientStaus: jobClientStatus('nodes', 'job'), +export default function jobClientStatus(nodesKey, jobKey) { + return computed( + `${nodesKey}.[]`, + `${jobKey}.{datacenters,status,allocations.@each.clientStatus,taskGroups}`, + function() { + const job = this.get(jobKey); + const nodes = this.get(nodesKey); + + // Filter nodes by the datacenters defined in the job. + const filteredNodes = nodes.filter(n => { + return job.datacenters.indexOf(n.datacenter) >= 0; + }); + + if (job.status === 'pending') { + return allQueued(filteredNodes); + } + + // Group the job allocations by the ID of the client that is running them. + const allocsByNodeID = {}; + job.allocations.forEach(a => { + const nodeId = a.node.get('id'); + if (!allocsByNodeID[nodeId]) { + allocsByNodeID[nodeId] = []; + } + allocsByNodeID[nodeId].push(a); + }); + + const result = { + byNode: {}, + byStatus: {}, + totalNodes: filteredNodes.length, + }; + filteredNodes.forEach(n => { + const status = jobStatus(allocsByNodeID[n.id], job.taskGroups.length); + result.byNode[n.id] = status; + + if (!result.byStatus[status]) { + result.byStatus[status] = []; + } + result.byStatus[status].push(n.id); + }); + result.byStatus = canonicalizeStatus(result.byStatus); + return result; + } + ); +} + +function allQueued(nodes) { + const nodeIDs = nodes.map(n => n.id); + return { + byNode: Object.fromEntries(nodeIDs.map(id => [id, 'queued'])), + byStatus: canonicalizeStatus({ queued: nodeIDs }), + totalNodes: nodes.length, + }; +} + +// canonicalizeStatus makes sure all possible statuses are present in the final +// returned object. Statuses missing from the input will be assigned an emtpy +// array. +function canonicalizeStatus(status) { + for (let i = 0; i < STATUS.length; i++) { + const s = STATUS[i]; + if (!status[s]) { + status[s] = []; + } + } + return status; +} + +// jobStatus computes the aggregated status of a job in a client. +// +// `allocs` are the list of allocations for a job that are placed in a specific +// client. +// `expected` is the number of allocations the client should have. +function jobStatus(allocs, expected) { + // The `pending` status has already been checked, so if at this point the + // client doesn't have any allocations we assume that it was not considered + // for scheduling for some reason. + if (!allocs) { + return 'notScheduled'; + } + + // If there are some allocations, but not how many we expected, the job is + // considered `degraded` since it did fully run in this client. + if (allocs.length < expected) { + return 'degraded'; + } + + // Count how many allocations are in each `clientStatus` value. + const summary = allocs + .filter(a => !a.isOld) + .reduce((acc, a) => { + const status = a.clientStatus; + if (!acc[status]) { + acc[status] = 0; + } + acc[status]++; + return acc; + }, {}); + + // Theses statuses are considered terminal, i.e., an allocation will never + // move from this status to another. + // If all of the expected allocations are in one of these statuses, the job + // as a whole is considered to be in the same status. + const terminalStatuses = ['failed', 'lost', 'complete']; + for (let i = 0; i < terminalStatuses.length; i++) { + const s = terminalStatuses[i]; + if (summary[s] === expected) { + return s; + } + } + + // It only takes one allocation to be in one of these statuses for the + // entire job to be considered in a given status. + if (summary['failed'] > 0 || summary['lost'] > 0) { + return 'degraded'; + } + + if (summary['running'] > 0) { + return 'running'; + } + + return 'starting'; +} diff --git a/ui/config/environment.js b/ui/config/environment.js index 35f112b9b50..fd9caf529fd 100644 --- a/ui/config/environment.js +++ b/ui/config/environment.js @@ -25,7 +25,8 @@ module.exports = function(environment) { APP: { blockingQueries: true, - mirageScenario: 'topoMedium', + // TODO: revert before merging to main. + mirageScenario: 'sysbatchSmall', // convert to 'sysbatchSmall' when working on feature mirageWithNamespaces: false, mirageWithTokens: true, mirageWithRegions: true, diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js index 1c914c7a991..bf135888f0c 100644 --- a/ui/mirage/factories/job.js +++ b/ui/mirage/factories/job.js @@ -6,7 +6,7 @@ import { DATACENTERS } from '../common'; const REF_TIME = new Date(); const JOB_PREFIXES = provide(5, faker.hacker.abbreviation); -const JOB_TYPES = ['service', 'batch', 'system']; +const JOB_TYPES = ['service', 'batch', 'system', 'sysbatch']; const JOB_STATUSES = ['pending', 'running', 'dead']; export default Factory.extend({ @@ -67,6 +67,20 @@ export default Factory.extend({ }), }), + periodicSysbatch: trait({ + type: 'sysbatch', + periodic: true, + // periodic details object + // serializer update for bool vs details object + periodicDetails: () => ({ + Enabled: true, + ProhibitOverlap: true, + Spec: '*/5 * * * * *', + SpecType: 'cron', + TimeZone: 'UTC', + }), + }), + parameterized: trait({ type: 'batch', parameterized: true, @@ -79,6 +93,18 @@ export default Factory.extend({ }), }), + parameterizedSysbatch: trait({ + type: 'sysbatch', + parameterized: true, + // parameterized job object + // serializer update for bool vs details object + parameterizedJob: () => ({ + MetaOptional: generateMetaFields(faker.random.number(10), 'optional'), + MetaRequired: generateMetaFields(faker.random.number(10), 'required'), + Payload: faker.random.boolean() ? 'required' : null, + }), + }), + periodicChild: trait({ // Periodic children need a parent job, // It is the Periodic job's responsibility to create @@ -86,6 +112,13 @@ export default Factory.extend({ type: 'batch', }), + periodicSysbatchChild: trait({ + // Periodic children need a parent job, + // It is the Periodic job's responsibility to create + // periodicChild jobs and provide a parent job. + type: 'sysbatch', + }), + parameterizedChild: trait({ // Parameterized children need a parent job, // It is the Parameterized job's responsibility to create @@ -96,6 +129,16 @@ export default Factory.extend({ payload: window.btoa(faker.lorem.sentence()), }), + parameterizedSysbatchChild: trait({ + // Parameterized children need a parent job, + // It is the Parameterized job's responsibility to create + // parameterizedChild jobs and provide a parent job. + type: 'sysbatch', + parameterized: true, + dispatched: true, + payload: window.btoa(faker.lorem.sentence()), + }), + createIndex: i => i, modifyIndex: () => faker.random.number({ min: 10, max: 2000 }), @@ -248,22 +291,44 @@ export default Factory.extend({ } if (job.periodic) { - // Create periodicChild jobs - server.createList('job', job.childrenCount, 'periodicChild', { + let childType; + switch (job.type) { + case 'batch': + childType = 'periodicChild'; + break; + case 'sysbatch': + childType = 'periodicSysbatchChild'; + break; + } + + // Create child jobs + server.createList('job', job.childrenCount, childType, { parentId: job.id, namespaceId: job.namespaceId, namespace: job.namespace, + datacenters: job.datacenters, createAllocations: job.createAllocations, shallow: job.shallow, }); } if (job.parameterized && !job.parentId) { - // Create parameterizedChild jobs - server.createList('job', job.childrenCount, 'parameterizedChild', { + let childType; + switch (job.type) { + case 'batch': + childType = 'parameterizedChild'; + break; + case 'sysbatch': + childType = 'parameterizedSysbatchChild'; + break; + } + + // Create child jobs + server.createList('job', job.childrenCount, childType, { parentId: job.id, namespaceId: job.namespaceId, namespace: job.namespace, + datacenters: job.datacenters, createAllocations: job.createAllocations, shallow: job.shallow, }); diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index 8d2423f86d5..6e5d11c123b 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -1,5 +1,6 @@ import config from 'nomad-ui/config/environment'; import * as topoScenarios from './topo'; +import * as sysbatchScenarios from './sysbatch'; import { pickOne } from '../utils'; const withNamespaces = getConfigValue('mirageWithNamespaces', false); @@ -16,6 +17,7 @@ const allScenarios = { everyFeature, emptyCluster, ...topoScenarios, + ...sysbatchScenarios, }; const scenario = getScenarioQueryParameter() || getConfigValue('mirageScenario', 'emptyCluster'); @@ -85,6 +87,8 @@ function allJobTypes(server) { server.create('job', { type: 'system' }); server.create('job', 'periodic'); server.create('job', 'parameterized'); + server.create('job', 'periodicSysbatch'); + server.create('job', 'parameterizedSysbatch'); server.create('job', { failedPlacements: true }); } diff --git a/ui/mirage/scenarios/sysbatch.js b/ui/mirage/scenarios/sysbatch.js new file mode 100644 index 00000000000..5bb4b57c757 --- /dev/null +++ b/ui/mirage/scenarios/sysbatch.js @@ -0,0 +1,83 @@ +export function sysbatchSmall(server) { + return sysbatchScenario(server, 15); +} + +export function sysbatchLarge(server) { + return sysbatchScenario(server, 55); +} + +function sysbatchScenario(server, clientCount) { + server.createList('agent', 3); + const clients = server.createList('node', clientCount, { + datacenter: 'dc1', + status: 'ready', + }); + + // Create some clients not targeted by the sysbatch job. + server.createList('node', 3, { + datacenter: 'dc3', + status: 'ready', + }); + + // Generate non-system/sysbatch job as counter-example. + server.create('job', { + status: 'running', + type: 'service', + resourceSpec: ['M: 256, C: 500'], + createAllocations: true, + }); + + ['system', 'sysbatch'].forEach(type => { + // Job with 1 task group. + const job1 = server.create('job', { + status: 'running', + datacenters: ['dc1', 'dc2'], + type, + resourceSpec: ['M: 256, C: 500'], + createAllocations: false, + }); + clients.forEach(c => { + server.create('allocation', { jobId: job1.id, nodeId: c.id }); + }); + + // Job with 2 task groups. + const job2 = server.create('job', { + status: 'running', + datacenters: ['dc1'], + type, + resourceSpec: ['M: 256, C: 500', 'M: 256, C: 500'], + createAllocations: false, + }); + clients.forEach(c => { + server.create('allocation', { jobId: job2.id, nodeId: c.id }); + server.create('allocation', { jobId: job2.id, nodeId: c.id }); + }); + + // Job with 3 task groups. + const job3 = server.create('job', { + status: 'running', + datacenters: ['dc1'], + type, + resourceSpec: ['M: 256, C: 500', 'M: 256, C: 500', 'M: 256, C: 500'], + createAllocations: false, + }); + clients.forEach(c => { + server.create('allocation', { jobId: job3.id, nodeId: c.id }); + server.create('allocation', { jobId: job3.id, nodeId: c.id }); + server.create('allocation', { jobId: job3.id, nodeId: c.id }); + }); + + // Job with client not scheduled. + const jobNotScheduled = server.create('job', { + status: 'running', + datacenters: ['dc1'], + type, + resourceSpec: ['M: 256, C: 500'], + createAllocations: false, + }); + clients.forEach((c, i) => { + if (i > clients.length - 3) return; + server.create('allocation', { jobId: jobNotScheduled.id, nodeId: c.id }); + }); + }); +} diff --git a/ui/tests/acceptance/job-clients-test.js b/ui/tests/acceptance/job-clients-test.js new file mode 100644 index 00000000000..34583f1d90b --- /dev/null +++ b/ui/tests/acceptance/job-clients-test.js @@ -0,0 +1,205 @@ +import { currentURL } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; +import Clients from 'nomad-ui/tests/pages/jobs/job/clients'; + +let job; +let clients; + +const makeSearchableClients = (server, job) => { + Array(10) + .fill(null) + .map((_, index) => { + const node = server.create('node', { + id: index < 5 ? `ffffff-dddddd-${index}` : `111111-222222-${index}`, + datacenter: 'dc1', + status: 'ready', + }); + server.create('allocation', { jobId: job.id, nodeId: node.id }); + }); +}; + +module('Acceptance | job clients', function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function() { + clients = server.createList('node', 12, { + datacenter: 'dc1', + status: 'ready', + }); + // Job with 1 task group. + job = server.create('job', { + status: 'running', + datacenters: ['dc1'], + type: 'sysbatch', + resourceSpec: ['M: 256, C: 500'], + createAllocations: false, + }); + clients.forEach(c => { + server.create('allocation', { jobId: job.id, nodeId: c.id }); + }); + + // Create clients without allocations to have some 'not scheduled' job status. + clients = clients.concat( + server.createList('node', 3, { + datacenter: 'dc1', + status: 'ready', + }) + ); + }); + + test('it passes an accessibility audit', async function(assert) { + await Clients.visit({ id: job.id }); + await a11yAudit(assert); + }); + + test('lists all clients for the job', async function(assert) { + await Clients.visit({ id: job.id }); + assert.equal(Clients.clients.length, 15, 'Clients are shown in a table'); + + const clientIDs = clients.sortBy('id').map(c => c.id); + const clientsInTable = Clients.clients.map(c => c.id).sort(); + assert.deepEqual(clientsInTable, clientIDs); + + assert.equal(document.title, `Job ${job.name} clients - Nomad`); + }); + + test('dates have tooltip', async function(assert) { + await Clients.visit({ id: job.id }); + + Clients.clients.forEach((clientRow, index) => { + const jobStatus = Clients.clientFor(clientRow.id).status; + + ['createTime', 'modifyTime'].forEach(col => { + if (jobStatus === 'not scheduled') { + assert.equal(clientRow[col].text, '-', `row ${index} doesn't have ${col} tooltip`); + return; + } + + const hasTooltip = clientRow[col].tooltip.isPresent; + const tooltipText = clientRow[col].tooltip.text; + assert.true(hasTooltip, `row ${index} has ${col} tooltip`); + assert.ok(tooltipText, `row ${index} has ${col} tooltip content ${tooltipText}`); + }); + }); + }); + + test('clients table is sortable', async function(assert) { + await Clients.visit({ id: job.id }); + await Clients.sortBy('node.name'); + + assert.equal( + currentURL(), + `/jobs/${job.id}/clients?desc=true&sort=node.name`, + 'the URL persists the sort parameter' + ); + + const sortedClients = clients.sortBy('name').reverse(); + Clients.clients.forEach((client, index) => { + const shortId = sortedClients[index].id.split('-')[0]; + assert.equal( + client.shortId, + shortId, + `Client ${index} is ${shortId} with name ${sortedClients[index].name}` + ); + }); + }); + + test('clients table is searchable', async function(assert) { + makeSearchableClients(server, job); + + await Clients.visit({ id: job.id }); + await Clients.search('ffffff'); + + assert.equal(Clients.clients.length, 5, 'List is filtered by search term'); + }); + + test('when a search yields no results, the search box remains', async function(assert) { + makeSearchableClients(server, job); + + await Clients.visit({ id: job.id }); + await Clients.search('^nothing will ever match this long regex$'); + + assert.equal( + Clients.emptyState.headline, + 'No Matches', + 'List is empty and the empty state is about search' + ); + + assert.ok(Clients.hasSearchBox, 'Search box is still shown'); + }); + + test('when the job for the clients is not found, an error message is shown, but the URL persists', async function(assert) { + await Clients.visit({ id: 'not-a-real-job' }); + + assert.equal( + server.pretender.handledRequests + .filter(request => !request.url.includes('policy')) + .findBy('status', 404).url, + '/v1/job/not-a-real-job', + 'A request to the nonexistent job is made' + ); + assert.equal(currentURL(), '/jobs/not-a-real-job/clients', 'The URL persists'); + assert.ok(Clients.error.isPresent, 'Error message is shown'); + assert.equal(Clients.error.title, 'Not Found', 'Error message is for 404'); + }); + + test('clicking row goes to client details', async function(assert) { + const client = clients[0]; + + await Clients.visit({ id: job.id }); + await Clients.clientFor(client.id).click(); + assert.equal(currentURL(), `/clients/${client.id}`); + + await Clients.visit({ id: job.id }); + await Clients.clientFor(client.id).visit(); + assert.equal(currentURL(), `/clients/${client.id}`); + + await Clients.visit({ id: job.id }); + await Clients.clientFor(client.id).visitRow(); + assert.equal(currentURL(), `/clients/${client.id}`); + }); + + testFacet('Job Status', { + facet: Clients.facets.jobStatus, + paramName: 'jobStatus', + expectedOptions: [ + 'Queued', + 'Not Scheduled', + 'Starting', + 'Running', + 'Complete', + 'Degraded', + 'Failed', + 'Lost', + ], + async beforeEach() { + await Clients.visit({ id: job.id }); + }, + }); + + function testFacet(label, { facet, paramName, beforeEach, expectedOptions }) { + test(`the ${label} facet has the correct options`, async function(assert) { + await beforeEach(); + await facet.toggle(); + + let expectation; + if (typeof expectedOptions === 'function') { + expectation = expectedOptions(); + } else { + expectation = expectedOptions; + } + + assert.deepEqual( + facet.options.map(option => option.label.trim()), + expectation, + `Options for facet ${paramName} are as expected` + ); + }); + + // TODO: add facet tests for actual list filtering + } +}); diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js index 74e02db9599..6ddc145ebb6 100644 --- a/ui/tests/acceptance/job-detail-test.js +++ b/ui/tests/acceptance/job-detail-test.js @@ -5,15 +5,85 @@ import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import moment from 'moment'; import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; -import moduleForJob from 'nomad-ui/tests/helpers/module-for-job'; +import moduleForJob, { moduleForJobWithClientStatus } from 'nomad-ui/tests/helpers/module-for-job'; import JobDetail from 'nomad-ui/tests/pages/jobs/detail'; moduleForJob('Acceptance | job detail (batch)', 'allocations', () => server.create('job', { type: 'batch', shallow: true }) ); + moduleForJob('Acceptance | job detail (system)', 'allocations', () => server.create('job', { type: 'system', shallow: true }) ); + +moduleForJobWithClientStatus('Acceptance | job detail with client status (system)', () => + server.create('job', { + status: 'running', + datacenters: ['dc1'], + type: 'system', + createAllocations: false, + }) +); + +moduleForJob('Acceptance | job detail (sysbatch)', 'allocations', () => + server.create('job', { type: 'sysbatch', shallow: true }) +); + +moduleForJobWithClientStatus('Acceptance | job detail with client status (sysbatch)', () => + server.create('job', { + status: 'running', + datacenters: ['dc1'], + type: 'sysbatch', + createAllocations: false, + }) +); + +moduleForJobWithClientStatus( + 'Acceptance | job detail with client status (sysbatch with namespace)', + () => { + const namespace = server.create('namespace', { id: 'test' }); + return server.create('job', { + status: 'running', + datacenters: ['dc1'], + type: 'sysbatch', + namespaceId: namespace.name, + createAllocations: false, + }); + } +); + +moduleForJob('Acceptance | job detail (sysbatch child)', 'allocations', () => { + const parent = server.create('job', 'periodicSysbatch', { + childrenCount: 1, + shallow: true, + datacenters: ['dc1'], + }); + return server.db.jobs.where({ parentId: parent.id })[0]; +}); + +moduleForJobWithClientStatus('Acceptance | job detail with client status (sysbatch child)', () => { + const parent = server.create('job', 'periodicSysbatch', { + childrenCount: 1, + shallow: true, + datacenters: ['dc1'], + }); + return server.db.jobs.where({ parentId: parent.id })[0]; +}); + +moduleForJobWithClientStatus( + 'Acceptance | job detail with client status (sysbatch child with namespace)', + () => { + const namespace = server.create('namespace', { id: 'test' }); + const parent = server.create('job', 'periodicSysbatch', { + childrenCount: 1, + shallow: true, + namespaceId: namespace.name, + datacenters: ['dc1'], + }); + return server.db.jobs.where({ parentId: parent.id })[0]; + } +); + moduleForJob( 'Acceptance | job detail (periodic)', 'children', diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index 5e5c2052e9c..ada33469646 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -217,7 +217,7 @@ module('Acceptance | jobs list', function(hooks) { testFacet('Type', { facet: JobsList.facets.type, paramName: 'type', - expectedOptions: ['Batch', 'Parameterized', 'Periodic', 'Service', 'System'], + expectedOptions: ['Batch', 'Parameterized', 'Periodic', 'Service', 'System', 'System Batch'], async beforeEach() { server.createList('job', 2, { createAllocations: false, type: 'batch' }); server.createList('job', 2, { diff --git a/ui/tests/helpers/module-for-job.js b/ui/tests/helpers/module-for-job.js index 0bf9f70e38f..d631a374ca7 100644 --- a/ui/tests/helpers/module-for-job.js +++ b/ui/tests/helpers/module-for-job.js @@ -119,10 +119,95 @@ export default function moduleForJob(title, context, jobFactory, additionalTests }); } +// eslint-disable-next-line ember/no-test-module-for +export function moduleForJobWithClientStatus(title, jobFactory, additionalTests) { + let job; + + module(title, function(hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function() { + const clients = server.createList('node', 3, { + datacenter: 'dc1', + status: 'ready', + }); + job = jobFactory(); + clients.forEach(c => { + server.create('allocation', { jobId: job.id, nodeId: c.id }); + }); + if (!job.namespace || job.namespace === 'default') { + await JobDetail.visit({ id: job.id }); + } else { + await JobDetail.visit({ id: job.id, namespace: job.namespace }); + } + }); + + test('the subnav links to clients', async function(assert) { + await JobDetail.tabFor('clients').visit(); + assert.equal( + currentURL(), + urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}/clients`, job.namespace) + ); + }); + + test('job status summary is shown in the overview', async function(assert) { + assert.ok( + JobDetail.jobClientStatusSummary.isPresent, + 'Summary bar is displayed in the Job Status in Client summary section' + ); + }); + + test('clicking legend item navigates to a pre-filtered clients table', async function(assert) { + const legendItem = JobDetail.jobClientStatusSummary.legend.clickableItems[0]; + const status = legendItem.label; + await legendItem.click(); + + const encodedStatus = encodeURIComponent(JSON.stringify([status])); + const expectedURL = new URL( + urlWithNamespace(`/jobs/${job.name}/clients?status=${encodedStatus}`, job.namespace), + window.location + ); + const gotURL = new URL(currentURL(), window.location); + assert.deepEqual(gotURL.path, expectedURL.path); + assert.deepEqual(gotURL.searchParams, expectedURL.searchParams); + }); + + test('clicking in a slice takes you to a pre-filtered clients table', async function(assert) { + const slice = JobDetail.jobClientStatusSummary.slices[0]; + const status = slice.label; + await slice.click(); + + const encodedStatus = encodeURIComponent(JSON.stringify([status])); + const expectedURL = new URL( + urlWithNamespace(`/jobs/${job.name}/clients?status=${encodedStatus}`, job.namespace), + window.location + ); + const gotURL = new URL(currentURL(), window.location); + assert.deepEqual(gotURL.pathname, expectedURL.pathname); + + // Sort and compare URL query params. + gotURL.searchParams.sort(); + expectedURL.searchParams.sort(); + assert.equal(gotURL.searchParams.toString(), expectedURL.searchParams.toString()); + }); + + for (var testName in additionalTests) { + test(testName, async function(assert) { + await additionalTests[testName].call(this, job, assert); + }); + } + }); +} + function urlWithNamespace(url, namespace) { if (!namespace || namespace === 'default') { return url; } - return `${url}?namespace=${namespace}`; + const parts = url.split('?'); + const params = new URLSearchParams(parts[1]); + params.set('namespace', namespace); + + return `${parts[0]}?${params.toString()}`; } diff --git a/ui/tests/integration/components/job-client-status-bar-test.js b/ui/tests/integration/components/job-client-status-bar-test.js new file mode 100644 index 00000000000..827c033c70d --- /dev/null +++ b/ui/tests/integration/components/job-client-status-bar-test.js @@ -0,0 +1,77 @@ +import { module, test } from 'qunit'; +import { create } from 'ember-cli-page-object'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import sinon from 'sinon'; +import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; +import jobClientStatusBar from 'nomad-ui/tests/pages/components/job-client-status-bar'; + +const JobClientStatusBar = create(jobClientStatusBar()); + +module('Integration | Component | job-client-status-bar', function(hooks) { + setupRenderingTest(hooks); + + const commonProperties = () => ({ + onSliceClick: sinon.spy(), + job: { + namespace: { + get: () => 'my-namespace', + }, + }, + jobClientStatus: { + byStatus: { + queued: [], + starting: ['someNodeId'], + running: [], + complete: [], + degraded: [], + failed: [], + lost: [], + notScheduled: [], + }, + }, + isNarrow: true, + }); + + const commonTemplate = hbs` + `; + + test('it renders', async function(assert) { + const props = commonProperties(); + this.setProperties(props); + await render(commonTemplate); + + assert.ok(JobClientStatusBar.isPresent, 'Client Status Bar is rendered'); + await componentA11yAudit(this.element, assert); + }); + + test('it fires the onBarClick handler method when clicking a bar in the chart', async function(assert) { + const props = commonProperties(); + this.setProperties(props); + await render(commonTemplate); + await JobClientStatusBar.slices[0].click(); + assert.ok(props.onSliceClick.calledOnce); + }); + + test('it handles an update to client status property', async function(assert) { + const props = commonProperties(); + this.setProperties(props); + await render(commonTemplate); + const newProps = { + ...props, + jobClientStatus: { + ...props.jobClientStatus, + byStatus: { ...props.jobClientStatus.byStatus, starting: [], running: ['someNodeId'] }, + }, + }; + this.setProperties(newProps); + await JobClientStatusBar.visitSlice('running'); + assert.ok(props.onSliceClick.calledOnce); + }); +}); diff --git a/ui/tests/pages/components/clients.js b/ui/tests/pages/components/clients.js new file mode 100644 index 00000000000..4be7fbee31c --- /dev/null +++ b/ui/tests/pages/components/clients.js @@ -0,0 +1,40 @@ +import { attribute, collection, clickable, text } from 'ember-cli-page-object'; +import { singularize } from 'ember-inflector'; + +export default function(selector = '[data-test-client]', propKey = 'clients') { + const lookupKey = `${singularize(propKey)}For`; + // Remove the bracket notation + const attr = selector.substring(1, selector.length - 1); + + return { + [propKey]: collection(selector, { + id: attribute(attr), + shortId: text('[data-test-short-id]'), + name: text('[data-test-name]'), + status: text('[data-test-job-status]'), + + createTime: { + scope: '[data-test-create-time]', + tooltip: { + scope: '.tooltip', + text: attribute('aria-label'), + }, + }, + + modifyTime: { + scope: '[data-test-modify-time]', + tooltip: { + scope: '.tooltip', + text: attribute('aria-label'), + }, + }, + + visit: clickable('[data-test-short-id] a'), + visitRow: clickable(), + }), + + [lookupKey]: function(id) { + return this[propKey].toArray().find(client => client.id === id); + }, + }; +} diff --git a/ui/tests/pages/components/job-client-status-bar.js b/ui/tests/pages/components/job-client-status-bar.js new file mode 100644 index 00000000000..3cd753159be --- /dev/null +++ b/ui/tests/pages/components/job-client-status-bar.js @@ -0,0 +1,37 @@ +import { attribute, clickable, collection } from 'ember-cli-page-object'; + +export default scope => ({ + scope, + + slices: collection('svg .bars g', { + label: attribute('data-test-slice-label'), + click: clickable(), + }), + + legend: { + scope: '.legend', + + items: collection('li', { + label: attribute('data-test-legent-label'), + }), + + clickableItems: collection('li.is-clickable', { + label: attribute('data-test-legent-label'), + click: clickable('a'), + }), + }, + + visitSlice: async function(label) { + await this.slices + .toArray() + .findBy('label', label) + .click(); + }, + + visitLegend: async function(label) { + await this.legend.clickableItems + .toArray() + .findBy('label', label) + .click(); + }, +}); diff --git a/ui/tests/pages/jobs/detail.js b/ui/tests/pages/jobs/detail.js index cc6b64634b9..92e083e2ab8 100644 --- a/ui/tests/pages/jobs/detail.js +++ b/ui/tests/pages/jobs/detail.js @@ -13,6 +13,7 @@ import { import allocations from 'nomad-ui/tests/pages/components/allocations'; import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button'; import recommendationAccordion from 'nomad-ui/tests/pages/components/recommendation-accordion'; +import jobClientStatusBar from 'nomad-ui/tests/pages/components/job-client-status-bar'; export default create({ visit: visitable('/jobs/:id'), @@ -59,6 +60,7 @@ export default create({ return this.stats.toArray().findBy('id', id); }, + jobClientStatusSummary: jobClientStatusBar('[data-test-job-client-status-bar]'), childrenSummary: isPresent('[data-test-job-summary] [data-test-children-status-bar]'), allocationsSummary: isPresent('[data-test-job-summary] [data-test-allocation-status-bar]'), diff --git a/ui/tests/pages/jobs/job/clients.js b/ui/tests/pages/jobs/job/clients.js new file mode 100644 index 00000000000..83d69fa9e51 --- /dev/null +++ b/ui/tests/pages/jobs/job/clients.js @@ -0,0 +1,49 @@ +import { + attribute, + clickable, + create, + collection, + fillable, + isPresent, + text, + visitable, +} from 'ember-cli-page-object'; +import { multiFacet } from 'nomad-ui/tests/pages/components/facet'; + +import clients from 'nomad-ui/tests/pages/components/clients'; +import error from 'nomad-ui/tests/pages/components/error'; + +export default create({ + visit: visitable('/jobs/:id/clients'), + pageSize: 25, + + hasSearchBox: isPresent('[data-test-clients-search]'), + search: fillable('[data-test-clients-search] input'), + + ...clients(), + + isEmpty: isPresent('[data-test-empty-clients-list]'), + emptyState: { + headline: text('[data-test-empty-clients-list-headline]'), + }, + + sortOptions: collection('[data-test-sort-by]', { + id: attribute('data-test-sort-by'), + sort: clickable(), + }), + + sortBy(id) { + return this.sortOptions + .toArray() + .findBy('id', id) + .sort(); + }, + + facets: { + jobStatus: multiFacet('[data-test-job-status-facet]'), + datacenter: multiFacet('[data-test-datacenter-facet]'), + clientClass: multiFacet('[data-test-class-facet]'), + }, + + error: error(), +}); diff --git a/ui/tests/unit/utils/job-client-status-test.js b/ui/tests/unit/utils/job-client-status-test.js new file mode 100644 index 00000000000..801f505ab23 --- /dev/null +++ b/ui/tests/unit/utils/job-client-status-test.js @@ -0,0 +1,319 @@ +import { module, test } from 'qunit'; +import jobClientStatus from 'nomad-ui/utils/properties/job-client-status'; +import EmberObject from '@ember/object'; + +class JobClientStatusMock extends EmberObject { + constructor(job, nodes) { + super(...arguments); + this.job = job; + this.nodes = nodes; + } + + @jobClientStatus('nodes', 'job') jobClientStatus; + + get(key) { + switch (key) { + case 'job': + return this.job; + case 'nodes': + return this.nodes; + } + } +} + +class NodeMock { + constructor(id, datacenter) { + this.id = id; + this.datacenter = datacenter; + } + + get(key) { + switch (key) { + case 'id': + return this.id; + } + } +} + +module('Unit | Util | JobClientStatus', function() { + test('it handles the case where all nodes are running', async function(assert) { + const node = new NodeMock('node-1', 'dc1'); + const nodes = [node]; + const job = { + datacenters: ['dc1'], + status: 'running', + allocations: [{ node, clientStatus: 'running' }], + taskGroups: [{}], + }; + const expected = { + byNode: { + 'node-1': 'running', + }, + byStatus: { + running: ['node-1'], + complete: [], + degraded: [], + failed: [], + lost: [], + notScheduled: [], + queued: [], + starting: [], + }, + totalNodes: 1, + }; + + const mock = new JobClientStatusMock(job, nodes); + let result = mock.jobClientStatus; + + assert.deepEqual(result, expected); + }); + + test('it handles the degraded case where a node has a failing allocation', async function(assert) { + const node = new NodeMock('node-2', 'dc1'); + const nodes = [node]; + const job = { + datacenters: ['dc1'], + status: 'running', + allocations: [ + { node, clientStatus: 'running' }, + { node, clientStatus: 'failed' }, + { node, clientStatus: 'running' }, + ], + taskGroups: [{}, {}, {}], + }; + const expected = { + byNode: { + 'node-2': 'degraded', + }, + byStatus: { + running: [], + complete: [], + degraded: ['node-2'], + failed: [], + lost: [], + notScheduled: [], + queued: [], + starting: [], + }, + totalNodes: 1, + }; + + const mock = new JobClientStatusMock(job, nodes); + let result = mock.jobClientStatus; + + assert.deepEqual(result, expected); + }); + + test('it handles the case where a node has all lost allocations', async function(assert) { + const node = new NodeMock('node-1', 'dc1'); + const nodes = [node]; + const job = { + datacenters: ['dc1'], + status: 'running', + allocations: [ + { node, clientStatus: 'lost' }, + { node, clientStatus: 'lost' }, + { node, clientStatus: 'lost' }, + ], + taskGroups: [{}, {}, {}], + }; + const expected = { + byNode: { + 'node-1': 'lost', + }, + byStatus: { + running: [], + complete: [], + degraded: [], + failed: [], + lost: ['node-1'], + notScheduled: [], + queued: [], + starting: [], + }, + totalNodes: 1, + }; + + const mock = new JobClientStatusMock(job, nodes); + let result = mock.jobClientStatus; + + assert.deepEqual(result, expected); + }); + + test('it handles the case where a node has all failed allocations', async function(assert) { + const node = new NodeMock('node-1', 'dc1'); + const nodes = [node]; + const job = { + datacenters: ['dc1'], + status: 'running', + allocations: [ + { node, clientStatus: 'failed' }, + { node, clientStatus: 'failed' }, + { node, clientStatus: 'failed' }, + ], + taskGroups: [{}, {}, {}], + }; + const expected = { + byNode: { + 'node-1': 'failed', + }, + byStatus: { + running: [], + complete: [], + degraded: [], + failed: ['node-1'], + lost: [], + notScheduled: [], + queued: [], + starting: [], + }, + totalNodes: 1, + }; + + const mock = new JobClientStatusMock(job, nodes); + let result = mock.jobClientStatus; + + assert.deepEqual(result, expected); + }); + + test('it handles the degraded case where the expected number of allocations doesnt match the actual number of allocations', async function(assert) { + const node = new NodeMock('node-1', 'dc1'); + const nodes = [node]; + const job = { + datacenters: ['dc1'], + status: 'running', + allocations: [ + { node, clientStatus: 'running' }, + { node, clientStatus: 'running' }, + { node, clientStatus: 'running' }, + ], + taskGroups: [{}, {}, {}, {}], + }; + const expected = { + byNode: { + 'node-1': 'degraded', + }, + byStatus: { + running: [], + complete: [], + degraded: ['node-1'], + failed: [], + lost: [], + notScheduled: [], + queued: [], + starting: [], + }, + totalNodes: 1, + }; + + const mock = new JobClientStatusMock(job, nodes); + let result = mock.jobClientStatus; + + assert.deepEqual(result, expected); + }); + + test('it handles the not scheduled case where a node has no allocations', async function(assert) { + const node = new NodeMock('node-1', 'dc1'); + const nodes = [node]; + const job = { + datacenters: ['dc1'], + status: 'running', + allocations: [], + taskGroups: [], + }; + const expected = { + byNode: { + 'node-1': 'notScheduled', + }, + byStatus: { + running: [], + complete: [], + degraded: [], + failed: [], + lost: [], + notScheduled: ['node-1'], + queued: [], + starting: [], + }, + totalNodes: 1, + }; + + const mock = new JobClientStatusMock(job, nodes); + let result = mock.jobClientStatus; + + assert.deepEqual(result, expected); + }); + + test('it handles the queued case where the job is pending', async function(assert) { + const node = new NodeMock('node-1', 'dc1'); + const nodes = [node]; + const job = { + datacenters: ['dc1'], + status: 'pending', + allocations: [ + { node, clientStatus: 'starting' }, + { node, clientStatus: 'starting' }, + { node, clientStatus: 'starting' }, + ], + taskGroups: [{}, {}, {}, {}], + }; + const expected = { + byNode: { + 'node-1': 'queued', + }, + byStatus: { + running: [], + complete: [], + degraded: [], + failed: [], + lost: [], + notScheduled: [], + queued: ['node-1'], + starting: [], + }, + totalNodes: 1, + }; + + const mock = new JobClientStatusMock(job, nodes); + let result = mock.jobClientStatus; + + assert.deepEqual(result, expected); + }); + + test('it filters nodes by the datacenter of the job', async function(assert) { + const node1 = new NodeMock('node-1', 'dc1'); + const node2 = new NodeMock('node-2', 'dc2'); + const nodes = [node1, node2]; + const job = { + datacenters: ['dc1'], + status: 'running', + allocations: [ + { node: node1, clientStatus: 'running' }, + { node: node2, clientStatus: 'failed' }, + { node: node1, clientStatus: 'running' }, + ], + taskGroups: [{}, {}], + }; + const expected = { + byNode: { + 'node-1': 'running', + }, + byStatus: { + running: ['node-1'], + complete: [], + degraded: [], + failed: [], + lost: [], + notScheduled: [], + queued: [], + starting: [], + }, + totalNodes: 1, + }; + + const mock = new JobClientStatusMock(job, nodes); + let result = mock.jobClientStatus; + + assert.deepEqual(result, expected); + }); +});