diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b5612e95984..7394460df3cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ FEATURES: * **Multiple Vault Namespaces (Enterprise)**: Support for multiple Vault Namespaces [[GH-8453](https://github.com/hashicorp/nomad/issues/8453)] + * **Scaling Observability UI**: View changes in task group scale (both manual and automatic) over time. [[GH-8551](https://github.com/hashicorp/nomad/issues/8551)] BUG FIXES: diff --git a/ui/app/adapters/job-scale.js b/ui/app/adapters/job-scale.js new file mode 100644 index 000000000000..f068e69a848e --- /dev/null +++ b/ui/app/adapters/job-scale.js @@ -0,0 +1,7 @@ +import WatchableNamespaceIDs from './watchable-namespace-ids'; + +export default class JobScaleAdapter extends WatchableNamespaceIDs { + urlForFindRecord(id, type, hash) { + return super.urlForFindRecord(id, 'job', hash, 'scale'); + } +} diff --git a/ui/app/adapters/job-summary.js b/ui/app/adapters/job-summary.js index 569311c6695a..62ce05a5b2da 100644 --- a/ui/app/adapters/job-summary.js +++ b/ui/app/adapters/job-summary.js @@ -1,12 +1,7 @@ -import Watchable from './watchable'; +import WatchableNamespaceIDs from './watchable-namespace-ids'; -export default class JobSummaryAdapter extends Watchable { +export default class JobSummaryAdapter extends WatchableNamespaceIDs { urlForFindRecord(id, type, hash) { - const [name, namespace] = JSON.parse(id); - let url = super.urlForFindRecord(name, 'job', hash) + '/summary'; - if (namespace && namespace !== 'default') { - url += `?namespace=${namespace}`; - } - return url; + return super.urlForFindRecord(id, 'job', hash, 'summary'); } } diff --git a/ui/app/adapters/watchable-namespace-ids.js b/ui/app/adapters/watchable-namespace-ids.js index 65471283ebe1..5ca12a368812 100644 --- a/ui/app/adapters/watchable-namespace-ids.js +++ b/ui/app/adapters/watchable-namespace-ids.js @@ -35,9 +35,10 @@ export default class WatchableNamespaceIDs extends Watchable { return associateNamespace(url, namespace); } - urlForFindRecord(id, type, hash) { + urlForFindRecord(id, type, hash, pathSuffix) { const [name, namespace] = JSON.parse(id); let url = super.urlForFindRecord(name, type, hash); + if (pathSuffix) url += `/${pathSuffix}`; return associateNamespace(url, namespace); } diff --git a/ui/app/components/json-viewer.js b/ui/app/components/json-viewer.js index bcb80309940e..148f3d70a6be 100644 --- a/ui/app/components/json-viewer.js +++ b/ui/app/components/json-viewer.js @@ -1,10 +1,11 @@ import Component from '@ember/component'; import { computed } from '@ember/object'; -import { classNames } from '@ember-decorators/component'; +import { classNames, classNameBindings } from '@ember-decorators/component'; import classic from 'ember-classic-decorator'; @classic @classNames('json-viewer') +@classNameBindings('fluidHeight:has-fluid-height') export default class JsonViewer extends Component { json = null; diff --git a/ui/app/controllers/jobs/job/task-group.js b/ui/app/controllers/jobs/job/task-group.js index c780f97220e0..876945b0aabf 100644 --- a/ui/app/controllers/jobs/job/task-group.js +++ b/ui/app/controllers/jobs/job/task-group.js @@ -1,7 +1,7 @@ import { inject as service } from '@ember/service'; import { alias, readOnly } from '@ember/object/computed'; import Controller from '@ember/controller'; -import { action, computed } from '@ember/object'; +import { action, computed, get } from '@ember/object'; import Sortable from 'nomad-ui/mixins/sortable'; import Searchable from 'nomad-ui/mixins/searchable'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; @@ -51,6 +51,15 @@ export default class TaskGroupController extends Controller.extend( @alias('listSorted') listToSearch; @alias('listSearched') sortedAllocations; + @computed('model.scaleState.events.@each.time', function() { + const events = get(this, 'model.scaleState.events'); + if (events) { + return events.sortBy('time').reverse(); + } + return []; + }) + sortedScaleEvents; + @computed('model.job.runningDeployment') get tooltipText() { if (this.can.cannot('scale job')) return "You aren't allowed to scale task groups"; diff --git a/ui/app/models/job-scale.js b/ui/app/models/job-scale.js new file mode 100644 index 000000000000..54424d9b6ecb --- /dev/null +++ b/ui/app/models/job-scale.js @@ -0,0 +1,11 @@ +import Model from 'ember-data/model'; +import { belongsTo } from 'ember-data/relationships'; +import { fragmentArray } from 'ember-data-model-fragments/attributes'; +import classic from 'ember-classic-decorator'; + +@classic +export default class JobSummary extends Model { + @belongsTo('job') job; + + @fragmentArray('task-group-scale') taskGroupScales; +} diff --git a/ui/app/models/job.js b/ui/app/models/job.js index 154fc07b1146..86717ea0af90 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -119,6 +119,7 @@ export default class Job extends Model { @hasMany('deployments') deployments; @hasMany('evaluations') evaluations; @belongsTo('namespace') namespace; + @belongsTo('job-scale') scaleState; @computed('taskGroups.@each.drivers') get drivers() { diff --git a/ui/app/models/scale-event.js b/ui/app/models/scale-event.js new file mode 100644 index 000000000000..e87c2a19cc28 --- /dev/null +++ b/ui/app/models/scale-event.js @@ -0,0 +1,34 @@ +import { computed } from '@ember/object'; +import Fragment from 'ember-data-model-fragments/fragment'; +import attr from 'ember-data/attr'; +import { fragmentOwner } from 'ember-data-model-fragments/attributes'; + +export default class ScaleEvent extends Fragment { + @fragmentOwner() taskGroupScale; + + @attr('number') count; + @attr('number') previousCount; + @attr('boolean') error; + @attr('string') evalId; + + @computed('count', function() { + return this.count != null; + }) + hasCount; + + @computed('count', 'previousCount', function() { + return this.count > this.previousCount; + }) + increased; + + @attr('date') time; + @attr('number') timeNanos; + + @attr('string') message; + @attr() meta; + + @computed('meta', function() { + return Object.keys(this.meta).length > 0; + }) + hasMeta; +} diff --git a/ui/app/models/task-group-scale.js b/ui/app/models/task-group-scale.js new file mode 100644 index 000000000000..dbc307d5fb47 --- /dev/null +++ b/ui/app/models/task-group-scale.js @@ -0,0 +1,23 @@ +import { computed } from '@ember/object'; +import Fragment from 'ember-data-model-fragments/fragment'; +import attr from 'ember-data/attr'; +import { fragmentOwner, fragmentArray } from 'ember-data-model-fragments/attributes'; + +export default class TaskGroupScale extends Fragment { + @fragmentOwner() jobScale; + + @attr('string') name; + + @attr('number') desired; + @attr('number') placed; + @attr('number') running; + @attr('number') healthy; + @attr('number') unhealthy; + + @fragmentArray('scale-event') events; + + @computed('events.length', function() { + return this.events.length; + }) + isVisible; +} diff --git a/ui/app/models/task-group.js b/ui/app/models/task-group.js index 4926f0affa79..e92c2c82c947 100644 --- a/ui/app/models/task-group.js +++ b/ui/app/models/task-group.js @@ -54,6 +54,11 @@ export default class TaskGroup extends Fragment { return maybe(this.get('job.taskGroupSummaries')).findBy('name', this.name); } + @computed('job.scaleState.taskGroupScales.[]') + get scaleState() { + return maybe(this.get('job.scaleState.taskGroupScales')).findBy('name', this.name); + } + scale(count, reason) { return this.job.scale(this.name, count, reason); } diff --git a/ui/app/routes/jobs/job/task-group.js b/ui/app/routes/jobs/job/task-group.js index 72c130c7edd5..e4f1ad9edd50 100644 --- a/ui/app/routes/jobs/job/task-group.js +++ b/ui/app/routes/jobs/job/task-group.js @@ -1,7 +1,7 @@ import Route from '@ember/routing/route'; import { collect } from '@ember/object/computed'; import EmberError from '@ember/error'; -import { resolve } from 'rsvp'; +import { resolve, all } from 'rsvp'; import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch'; import WithWatchers from 'nomad-ui/mixins/with-watchers'; import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; @@ -43,10 +43,9 @@ export default class TaskGroupRoute extends Route.extend(WithWatchers) { } // Refresh job allocations before-hand (so page sort works on load) - return job - .hasMany('allocations') - .reload() - .then(() => taskGroup); + return all([job.hasMany('allocations').reload(), job.get('scaleState')]).then( + () => taskGroup + ); }) .catch(notifyError(this)); } @@ -57,6 +56,7 @@ export default class TaskGroupRoute extends Route.extend(WithWatchers) { controller.set('watchers', { job: this.watchJob.perform(job), summary: this.watchSummary.perform(job.get('summary')), + scale: this.watchScale.perform(job.get('scaleState')), allocations: this.watchAllocations.perform(job), latestDeployment: job.get('supportsDeployments') && this.watchLatestDeployment.perform(job), }); @@ -65,8 +65,10 @@ export default class TaskGroupRoute extends Route.extend(WithWatchers) { @watchRecord('job') watchJob; @watchRecord('job-summary') watchSummary; + @watchRecord('job-scale') watchScale; @watchRelationship('allocations') watchAllocations; @watchRelationship('latestDeployment') watchLatestDeployment; - @collect('watchJob', 'watchSummary', 'watchAllocations', 'watchLatestDeployment') watchers; + @collect('watchJob', 'watchSummary', 'watchScale', 'watchAllocations', 'watchLatestDeployment') + watchers; } diff --git a/ui/app/serializers/job-scale.js b/ui/app/serializers/job-scale.js new file mode 100644 index 000000000000..b0ab04d874a8 --- /dev/null +++ b/ui/app/serializers/job-scale.js @@ -0,0 +1,19 @@ +import { assign } from '@ember/polyfills'; +import ApplicationSerializer from './application'; + +export default class JobScale extends ApplicationSerializer { + normalize(modelClass, hash) { + // Transform the map-based TaskGroups object into an array-based + // TaskGroupScale fragment list + hash.PlainJobId = hash.JobID; + hash.ID = JSON.stringify([hash.JobID, hash.Namespace || 'default']); + hash.JobID = hash.ID; + + const taskGroups = hash.TaskGroups || {}; + hash.TaskGroupScales = Object.keys(taskGroups).map(key => { + return assign(taskGroups[key], { Name: key }); + }); + + return super.normalize(modelClass, hash); + } +} diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js index ded935c0036e..d7f8c760ad7e 100644 --- a/ui/app/serializers/job.js +++ b/ui/app/serializers/job.js @@ -84,6 +84,11 @@ export default class JobSerializer extends ApplicationSerializer { related: buildURL(`${jobURL}/evaluations`, { namespace }), }, }, + scaleState: { + links: { + related: buildURL(`${jobURL}/scale`, { namespace }), + }, + }, }); } } diff --git a/ui/app/serializers/scale-event.js b/ui/app/serializers/scale-event.js new file mode 100644 index 000000000000..758ec3040822 --- /dev/null +++ b/ui/app/serializers/scale-event.js @@ -0,0 +1,10 @@ +import ApplicationSerializer from './application'; + +export default class ScaleEventSerializer extends ApplicationSerializer { + normalize(typeHash, hash) { + hash.TimeNanos = hash.Time % 1000000; + hash.Time = Math.floor(hash.Time / 1000000); + + return super.normalize(typeHash, hash); + } +} diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index ae08d6addb74..ea66946de4a1 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -18,6 +18,7 @@ @import './components/image-file.scss'; @import './components/inline-definitions'; @import './components/job-diff'; +@import './components/json-viewer'; @import './components/lifecycle-chart'; @import './components/loading-spinner'; @import './components/metrics'; diff --git a/ui/app/styles/components/accordion.scss b/ui/app/styles/components/accordion.scss index 3aaa057e5c4f..2b59864a2cda 100644 --- a/ui/app/styles/components/accordion.scss +++ b/ui/app/styles/components/accordion.scss @@ -15,6 +15,10 @@ border-bottom-left-radius: $radius; border-bottom-right-radius: $radius; } + + &.is-full-bleed { + padding: 0; + } } .accordion-head { @@ -26,10 +30,6 @@ background: $white; } - &.is-inactive { - color: $grey-light; - } - .accordion-head-content { width: 100%; margin-right: 1.5em; diff --git a/ui/app/styles/components/inline-definitions.scss b/ui/app/styles/components/inline-definitions.scss index a7927391986d..50f72b905435 100644 --- a/ui/app/styles/components/inline-definitions.scss +++ b/ui/app/styles/components/inline-definitions.scss @@ -8,6 +8,10 @@ font-weight: $weight-semibold; } + &.is-faded { + color: darken($grey-blue, 20%); + } + .pair { margin-right: 2em; white-space: nowrap; @@ -27,6 +31,14 @@ } } + .icon-field { + display: flex; + margin-left: -1em; + .icon-container { + width: 1.5em; + } + } + &.is-small { font-size: $size-7; } diff --git a/ui/app/styles/components/json-viewer.scss b/ui/app/styles/components/json-viewer.scss new file mode 100644 index 000000000000..68b322c79772 --- /dev/null +++ b/ui/app/styles/components/json-viewer.scss @@ -0,0 +1,5 @@ +.json-viewer { + &.has-fluid-height .CodeMirror-scroll { + min-height: 0; + } +} diff --git a/ui/app/templates/components/json-viewer.hbs b/ui/app/templates/components/json-viewer.hbs index bdf47e509521..d5f3cc576696 100644 --- a/ui/app/templates/components/json-viewer.hbs +++ b/ui/app/templates/components/json-viewer.hbs @@ -1,4 +1,5 @@ +
{{yield}}
{{/if}} diff --git a/ui/app/templates/components/scale-events-accordion.hbs b/ui/app/templates/components/scale-events-accordion.hbs new file mode 100644 index 000000000000..8ed0ec89373e --- /dev/null +++ b/ui/app/templates/components/scale-events-accordion.hbs @@ -0,0 +1,35 @@ + + +
+
+ + + {{#if a.item.error}}{{x-icon "cancel-circle-fill" class="is-danger"}}{{/if}} + + {{format-month-ts a.item.time}} + +
+
+ {{#if a.item.hasCount}} + + {{#if a.item.increased}} + {{x-icon "arrow-up" class="is-danger"}} + {{else}} + {{x-icon "arrow-down" class="is-primary"}} + {{/if}} + + {{a.item.count}} + {{/if}} +
+
+ {{a.item.message}} +
+
+
+ + + +
diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index c5a2baf4a047..fa4114ff9cd1 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -137,6 +137,17 @@ + {{#if this.model.scaleState.isVisible}} +
+
+ Recent Scaling Events +
+
+ +
+
+ {{/if}} + {{#if this.model.volumes.length}}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 80c521791ded..c85d4c15c059 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -157,6 +157,14 @@ export default function() { return deployment ? this.serialize(deployment) : new Response(200, {}, 'null'); }); + this.get( + '/job/:id/scale', + withBlockingSupport(function({ jobScales }, { params }) { + const obj = jobScales.findBy({ jobId: params.id }); + return this.serialize(jobScales.findBy({ jobId: params.id })); + }) + ); + this.post('/job/:id/periodic/force', function(schema, { params }) { // Create the child job const parent = schema.jobs.find(params.id); diff --git a/ui/mirage/factories/job-scale.js b/ui/mirage/factories/job-scale.js new file mode 100644 index 000000000000..168f2c0e9e8d --- /dev/null +++ b/ui/mirage/factories/job-scale.js @@ -0,0 +1,26 @@ +import { Factory, trait } from 'ember-cli-mirage'; +import faker from 'nomad-ui/mirage/faker'; + +export default Factory.extend({ + groupNames: [], + + jobId: '', + JobID() { + return this.jobId; + }, + namespace: null, + shallow: false, + + afterCreate(jobScale, server) { + const groups = jobScale.groupNames.map(group => + server.create('task-group-scale', { + id: group, + shallow: jobScale.shallow, + }) + ); + + jobScale.update({ + taskGroupScaleIds: groups.mapBy('id'), + }); + }, +}); diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js index 90e2f27b3949..c09eae9d855a 100644 --- a/ui/mirage/factories/job.js +++ b/ui/mirage/factories/job.js @@ -158,6 +158,17 @@ export default Factory.extend({ job_summary_id: jobSummary.id, }); + const jobScale = server.create('job-scale', { + groupNames: groups.mapBy('name'), + jobId: job.id, + namespace: job.namespace, + shallow: job.shallow, + }); + + job.update({ + jobScaleId: jobScale.id, + }); + if (!job.noDeployments) { Array(faker.random.number({ min: 1, max: 3 })) .fill(null) diff --git a/ui/mirage/factories/scale-event.js b/ui/mirage/factories/scale-event.js new file mode 100644 index 000000000000..eeb7399d34ac --- /dev/null +++ b/ui/mirage/factories/scale-event.js @@ -0,0 +1,20 @@ +import { Factory, trait } from 'ember-cli-mirage'; +import faker from 'nomad-ui/mirage/faker'; + +const REF_TIME = new Date(); + +export default Factory.extend({ + time: () => faker.date.past(2 / 365, REF_TIME) * 1000000, + count: () => faker.random.number(10), + previousCount: () => faker.random.number(10), + error: () => faker.random.number(10) > 8, + message: 'Sample message for a job scale event', + meta: () => + faker.random.number(10) < 8 + ? { + 'nomad_autoscaler.count.capped': true, + 'nomad_autoscaler.count.original': 0, + 'nomad_autoscaler.reason_history': ['scaling down because factor is 0.000000'], + } + : {}, +}); diff --git a/ui/mirage/factories/task-group-scale.js b/ui/mirage/factories/task-group-scale.js new file mode 100644 index 000000000000..a7f20fc61093 --- /dev/null +++ b/ui/mirage/factories/task-group-scale.js @@ -0,0 +1,26 @@ +import { Factory, trait } from 'ember-cli-mirage'; +import faker from 'nomad-ui/mirage/faker'; + +export default Factory.extend({ + name() { + return this.id; + }, + + desired: 1, + placed: 1, + running: 1, + healthy: 1, + unhealthy: 1, + + shallow: false, + + afterCreate(taskGroupScale, server) { + if (!taskGroupScale.shallow) { + const events = server.createList('scale-event', faker.random.number({ min: 1, max: 10 })); + + taskGroupScale.update({ + eventIds: events.mapBy('id'), + }); + } + }, +}); diff --git a/ui/mirage/models/job-scale.js b/ui/mirage/models/job-scale.js new file mode 100644 index 000000000000..8e06eead0601 --- /dev/null +++ b/ui/mirage/models/job-scale.js @@ -0,0 +1,6 @@ +import { Model, belongsTo, hasMany } from 'ember-cli-mirage'; + +export default Model.extend({ + job: belongsTo(), + taskGroupScales: hasMany(), +}); diff --git a/ui/mirage/models/job.js b/ui/mirage/models/job.js index 3a69c028164f..da8bc0c0c3ae 100644 --- a/ui/mirage/models/job.js +++ b/ui/mirage/models/job.js @@ -3,4 +3,5 @@ import { Model, hasMany, belongsTo } from 'ember-cli-mirage'; export default Model.extend({ task_groups: hasMany('task-group'), job_summary: belongsTo('job-summary'), + jobScale: belongsTo('job-scale'), }); diff --git a/ui/mirage/models/scale-event.js b/ui/mirage/models/scale-event.js new file mode 100644 index 000000000000..3c41f3573fab --- /dev/null +++ b/ui/mirage/models/scale-event.js @@ -0,0 +1,5 @@ +import { Model, belongsTo } from 'ember-cli-mirage'; + +export default Model.extend({ + taskGroupScale: belongsTo(), +}); diff --git a/ui/mirage/models/task-group-scale.js b/ui/mirage/models/task-group-scale.js new file mode 100644 index 000000000000..5262995032a9 --- /dev/null +++ b/ui/mirage/models/task-group-scale.js @@ -0,0 +1,6 @@ +import { Model, belongsTo, hasMany } from 'ember-cli-mirage'; + +export default Model.extend({ + jobScale: belongsTo(), + events: hasMany('scale-event'), +}); diff --git a/ui/mirage/serializers/job-scale.js b/ui/mirage/serializers/job-scale.js new file mode 100644 index 000000000000..e01e3df70a4f --- /dev/null +++ b/ui/mirage/serializers/job-scale.js @@ -0,0 +1,21 @@ +import ApplicationSerializer from './application'; +import { arrToObj } from '../utils'; + +export default ApplicationSerializer.extend({ + embed: true, + include: ['taskGroupScales'], + + serialize() { + var json = ApplicationSerializer.prototype.serialize.apply(this, arguments); + if (json instanceof Array) { + json.forEach(serializeJobScale); + } else { + serializeJobScale(json); + } + return json; + }, +}); + +function serializeJobScale(jobScale) { + jobScale.TaskGroups = jobScale.TaskGroupScales.reduce(arrToObj('Name'), {}); +} diff --git a/ui/mirage/serializers/task-group-scale.js b/ui/mirage/serializers/task-group-scale.js new file mode 100644 index 000000000000..506c19038f85 --- /dev/null +++ b/ui/mirage/serializers/task-group-scale.js @@ -0,0 +1,6 @@ +import ApplicationSerializer from './application'; + +export default ApplicationSerializer.extend({ + embed: true, + include: ['events'], +}); diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js index 3f667bd3f4c0..4f842d05b414 100644 --- a/ui/tests/acceptance/task-group-detail-test.js +++ b/ui/tests/acceptance/task-group-detail-test.js @@ -329,7 +329,9 @@ module('Acceptance | task group detail', function(hooks) { await TaskGroup.countStepper.increment.click(); await settled(); - const scaleRequest = server.pretender.handledRequests.find(req => req.url.endsWith('/scale')); + const scaleRequest = server.pretender.handledRequests.find( + req => req.method === 'POST' && req.url.endsWith('/scale') + ); const requestBody = JSON.parse(scaleRequest.requestBody); assert.equal(requestBody.Target.Group, scalingGroup.name); assert.equal(requestBody.Count, scalingGroup.count + 1); @@ -404,4 +406,39 @@ module('Acceptance | task group detail', function(hooks) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); }, }); + + test('when a task group has no scaling events, there is no recent scaling events section', async function(assert) { + const taskGroupScale = job.jobScale.taskGroupScales.models.find(m => m.name === taskGroup.name); + taskGroupScale.update({ events: [] }); + taskGroupScale.save(); + + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + + assert.notOk(TaskGroup.hasScaleEvents); + }); + + test('the recent scaling events section shows all recent scaling events in reverse chronological order', async function(assert) { + const taskGroupScale = job.jobScale.taskGroupScales.models.find(m => m.name === taskGroup.name); + const scaleEvents = taskGroupScale.events.models.sortBy('time').reverse(); + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + + assert.ok(TaskGroup.hasScaleEvents); + + scaleEvents.forEach((scaleEvent, idx) => { + const ScaleEvent = TaskGroup.scaleEvents[idx]; + assert.equal(ScaleEvent.time, moment(scaleEvent.time / 1000000).format('MMM DD HH:mm:ss ZZ')); + assert.equal(ScaleEvent.message, scaleEvent.message); + assert.equal(ScaleEvent.count, scaleEvent.count); + + if (scaleEvent.error) { + assert.ok(ScaleEvent.error); + } + + if (Object.keys(scaleEvent.meta).length) { + assert.ok(ScaleEvent.isToggleable); + } else { + assert.notOk(ScaleEvent.isToggleable); + } + }); + }); }); diff --git a/ui/tests/integration/components/scale-events-accordion-test.js b/ui/tests/integration/components/scale-events-accordion-test.js new file mode 100644 index 000000000000..d003e347511b --- /dev/null +++ b/ui/tests/integration/components/scale-events-accordion-test.js @@ -0,0 +1,134 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { click, find, findAll, render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import setupCodeMirror from 'nomad-ui/tests/helpers/codemirror'; +import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer'; + +module('Integration | Component | scale-events-accordion', function(hooks) { + setupRenderingTest(hooks); + setupCodeMirror(hooks); + + hooks.beforeEach(function() { + fragmentSerializerInitializer(this.owner); + this.store = this.owner.lookup('service:store'); + this.server = startMirage(); + this.server.create('node'); + this.taskGroupWithEvents = async function(events) { + const job = this.server.create('job', { createAllocations: false }); + const group = job.task_groups.models[0]; + job.jobScale.taskGroupScales.models.findBy('name', group.name).update({ events }); + + const jobModel = await this.store.find('job', JSON.stringify([job.id, 'default'])); + await jobModel.get('scaleState'); + return jobModel.taskGroups.findBy('name', group.name); + }; + }); + + const commonTemplate = hbs``; + + test('it shows an accordion with an entry for each event', async function(assert) { + const eventCount = 5; + const taskGroup = await this.taskGroupWithEvents(server.createList('scale-event', eventCount)); + this.set('events', taskGroup.scaleState.events); + + await render(commonTemplate); + + assert.equal(findAll('[data-test-scale-events] [data-test-accordion-head]').length, eventCount); + }); + + test('when an event is an error, an error icon is shown', async function(assert) { + const taskGroup = await this.taskGroupWithEvents( + server.createList('scale-event', 1, { error: true }) + ); + this.set('events', taskGroup.scaleState.events); + + await render(commonTemplate); + + assert.ok(find('[data-test-error]')); + }); + + test('when an event has a count higher than previous count, a danger up arrow is shown', async function(assert) { + const count = 5; + const taskGroup = await this.taskGroupWithEvents( + server.createList('scale-event', 1, { count, previousCount: count - 1, error: false }) + ); + this.set('events', taskGroup.scaleState.events); + + await render(commonTemplate); + + assert.notOk(find('[data-test-error]')); + assert.equal(find('[data-test-count]').textContent, count); + assert.ok( + find('[data-test-count-icon]') + .querySelector('.icon') + .classList.contains('is-danger') + ); + }); + + test('when an event has a count lower than previous count, a primary down arrow is shown', async function(assert) { + const count = 5; + const taskGroup = await this.taskGroupWithEvents( + server.createList('scale-event', 1, { count, previousCount: count + 1, error: false }) + ); + this.set('events', taskGroup.scaleState.events); + + await render(commonTemplate); + + assert.notOk(find('[data-test-error]')); + assert.equal(find('[data-test-count]').textContent, count); + assert.ok( + find('[data-test-count-icon]') + .querySelector('.icon') + .classList.contains('is-primary') + ); + }); + + test('when an event has no count, the count is omitted', async function(assert) { + const taskGroup = await this.taskGroupWithEvents( + server.createList('scale-event', 1, { count: null }) + ); + this.set('events', taskGroup.scaleState.events); + + await render(commonTemplate); + + assert.notOk(find('[data-test-count]')); + assert.notOk(find('[data-test-count-icon]')); + }); + + test('when an event has no meta properties, the accordion entry is not expandable', async function(assert) { + const taskGroup = await this.taskGroupWithEvents( + server.createList('scale-event', 1, { meta: {} }) + ); + this.set('events', taskGroup.scaleState.events); + + await render(commonTemplate); + + assert.ok(find('[data-test-accordion-toggle]').classList.contains('is-invisible')); + }); + + test('when an event has meta properties, the accordion entry is expanding, presenting the meta properties in a json viewer', async function(assert) { + const meta = { + prop: 'one', + prop2: 'two', + deep: { + prop: 'here', + 'dot.separate.prop': 12, + }, + }; + const taskGroup = await this.taskGroupWithEvents(server.createList('scale-event', 1, { meta })); + this.set('events', taskGroup.scaleState.events); + + await render(commonTemplate); + assert.notOk(find('[data-test-accordion-body]')); + + await click('[data-test-accordion-toggle]'); + assert.ok(find('[data-test-accordion-body]')); + + assert.equal( + getCodeMirrorInstance('[data-test-json-viewer]').getValue(), + JSON.stringify(meta, null, 2) + ); + }); +}); diff --git a/ui/tests/pages/jobs/job/task-group.js b/ui/tests/pages/jobs/job/task-group.js index d69b267501fe..0bda5a76931b 100644 --- a/ui/tests/pages/jobs/job/task-group.js +++ b/ui/tests/pages/jobs/job/task-group.js @@ -53,6 +53,22 @@ export default create({ permissions: text('[data-test-volume-permissions]'), }), + hasScaleEvents: isPresent('[data-test-scale-events]'), + scaleEvents: collection('[data-test-scale-events] [data-test-accordion-head]', { + error: isPresent('[data-test-error]'), + time: text('[data-test-time]'), + count: text('[data-test-count]'), + countIcon: { scope: '[data-test-count-icon]' }, + message: text('[data-test-message]'), + + isToggleable: isPresent('[data-test-accordion-toggle]:not(.is-invisible)'), + toggle: clickable('[data-test-accordion-toggle]'), + }), + + scaleEventBodies: collection('[data-test-scale-events] [data-test-accordion-body]', { + meta: text(), + }), + error: error(), emptyState: {