From a9004faa11c76b6d4a6be7ae51c5b98f4dbe3a1d Mon Sep 17 00:00:00 2001 From: Jasmine Dahilig Date: Thu, 30 Apr 2020 06:15:19 -0700 Subject: [PATCH] UI: Add representations for task lifecycles (#7659) This adds details about task lifecycles to allocations, task groups, and tasks. It includes a live-updating timeline-like chart on allocations. --- CHANGELOG.md | 3 + ui/app/components/lifecycle-chart-row.js | 18 +++ ui/app/components/lifecycle-chart.js | 61 +++++++++ .../allocations/allocation/task/index.js | 9 ++ ui/app/models/lifecycle.js | 10 ++ ui/app/models/task.js | 11 +- ui/app/styles/components.scss | 1 + ui/app/styles/components/lifecycle-chart.scss | 123 ++++++++++++++++++ ui/app/styles/utils/structure-colors.scss | 2 + .../allocations/allocation/index.hbs | 2 + .../allocations/allocation/task/index.hbs | 36 +++++ .../components/lifecycle-chart-row.hbs | 16 +++ .../templates/components/lifecycle-chart.hbs | 33 +++++ ui/app/templates/jobs/job/task-group.hbs | 2 + ui/mirage/factories/task.js | 12 ++ ui/tests/acceptance/allocation-detail-test.js | 105 +++++++++++++++ ui/tests/acceptance/task-detail-test.js | 84 ++++++++++++ ui/tests/acceptance/task-group-detail-test.js | 28 ++++ .../components/lifecycle-chart-test.js | 101 ++++++++++++++ ui/tests/pages/allocations/detail.js | 3 + ui/tests/pages/allocations/task/detail.js | 12 +- ui/tests/pages/components/lifecycle-chart.js | 27 ++++ ui/tests/pages/jobs/job/task-group.js | 3 + 23 files changed, 700 insertions(+), 2 deletions(-) create mode 100644 ui/app/components/lifecycle-chart-row.js create mode 100644 ui/app/components/lifecycle-chart.js create mode 100644 ui/app/models/lifecycle.js create mode 100644 ui/app/styles/components/lifecycle-chart.scss create mode 100644 ui/app/templates/components/lifecycle-chart-row.hbs create mode 100644 ui/app/templates/components/lifecycle-chart.hbs create mode 100644 ui/tests/integration/components/lifecycle-chart-test.js create mode 100644 ui/tests/pages/components/lifecycle-chart.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 57d3ecbd1..4196a2e03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## 0.11.2 (Unreleased) +FEATURES: + * **Task dependencies UI**: task lifecycle charts and details + BUG FIXES: * api: autoscaling policies should not be returned for stopped jobs [[GH-7768](https://github.com/hashicorp/nomad/issues/7768)] diff --git a/ui/app/components/lifecycle-chart-row.js b/ui/app/components/lifecycle-chart-row.js new file mode 100644 index 000000000..5a3a5e9dd --- /dev/null +++ b/ui/app/components/lifecycle-chart-row.js @@ -0,0 +1,18 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; + +export default Component.extend({ + tagName: '', + + activeClass: computed('taskState.state', function() { + if (this.taskState && this.taskState.state === 'running') { + return 'is-active'; + } + }), + + finishedClass: computed('taskState.finishedAt', function() { + if (this.taskState && this.taskState.finishedAt) { + return 'is-finished'; + } + }), +}); diff --git a/ui/app/components/lifecycle-chart.js b/ui/app/components/lifecycle-chart.js new file mode 100644 index 000000000..f15b281d9 --- /dev/null +++ b/ui/app/components/lifecycle-chart.js @@ -0,0 +1,61 @@ +import Component from '@ember/component'; +import { computed } from '@ember/object'; +import { sort } from '@ember/object/computed'; + +export default Component.extend({ + tagName: '', + + tasks: null, + taskStates: null, + + lifecyclePhases: computed('tasks.@each.lifecycle', 'taskStates.@each.state', function() { + const tasksOrStates = this.taskStates || this.tasks; + const lifecycles = { + prestarts: [], + sidecars: [], + mains: [], + }; + + tasksOrStates.forEach(taskOrState => { + const task = taskOrState.task || taskOrState; + lifecycles[`${task.lifecycleName}s`].push(taskOrState); + }); + + const phases = []; + + if (lifecycles.prestarts.length || lifecycles.sidecars.length) { + phases.push({ + name: 'Prestart', + isActive: lifecycles.prestarts.some(state => state.state === 'running'), + }); + } + + if (lifecycles.sidecars.length || lifecycles.mains.length) { + phases.push({ + name: 'Main', + isActive: lifecycles.mains.some(state => state.state === 'running'), + }); + } + + return phases; + }), + + sortedLifecycleTaskStates: sort('taskStates', function(a, b) { + return getTaskSortPrefix(a.task).localeCompare(getTaskSortPrefix(b.task)); + }), + + sortedLifecycleTasks: sort('tasks', function(a, b) { + return getTaskSortPrefix(a).localeCompare(getTaskSortPrefix(b)); + }), +}); + +const lifecycleNameSortPrefix = { + prestart: 0, + sidecar: 1, + main: 2, +}; + +function getTaskSortPrefix(task) { + // Prestarts first, then sidecars, then mains + return `${lifecycleNameSortPrefix[task.lifecycleName]}-${task.name}`; +} diff --git a/ui/app/controllers/allocations/allocation/task/index.js b/ui/app/controllers/allocations/allocation/task/index.js index 9a8733d92..8c26f7854 100644 --- a/ui/app/controllers/allocations/allocation/task/index.js +++ b/ui/app/controllers/allocations/allocation/task/index.js @@ -5,6 +5,15 @@ import { alias } from '@ember/object/computed'; import { task } from 'ember-concurrency'; export default Controller.extend({ + otherTaskStates: computed('model.task.taskGroup.tasks.@each.name', function() { + const taskName = this.model.task.name; + return this.model.allocation.states.rejectBy('name', taskName); + }), + + prestartTaskStates: computed('otherTaskStates.@each.lifecycle', function() { + return this.otherTaskStates.filterBy('task.lifecycle'); + }), + network: alias('model.resources.networks.firstObject'), ports: computed('network.reservedPorts.[]', 'network.dynamicPorts.[]', function() { return (this.get('network.reservedPorts') || []) diff --git a/ui/app/models/lifecycle.js b/ui/app/models/lifecycle.js new file mode 100644 index 000000000..0381be2f1 --- /dev/null +++ b/ui/app/models/lifecycle.js @@ -0,0 +1,10 @@ +import attr from 'ember-data/attr'; +import Fragment from 'ember-data-model-fragments/fragment'; +import { fragmentOwner } from 'ember-data-model-fragments/attributes'; + +export default Fragment.extend({ + task: fragmentOwner(), + + hook: attr('string'), + sidecar: attr('boolean'), +}); diff --git a/ui/app/models/task.js b/ui/app/models/task.js index 2ca3f59ed..7d5061272 100644 --- a/ui/app/models/task.js +++ b/ui/app/models/task.js @@ -1,6 +1,7 @@ import attr from 'ember-data/attr'; import Fragment from 'ember-data-model-fragments/fragment'; -import { fragmentArray, fragmentOwner } from 'ember-data-model-fragments/attributes'; +import { fragment, fragmentArray, fragmentOwner } from 'ember-data-model-fragments/attributes'; +import { computed } from '@ember/object'; export default Fragment.extend({ taskGroup: fragmentOwner(), @@ -9,6 +10,14 @@ export default Fragment.extend({ driver: attr('string'), kind: attr('string'), + lifecycle: fragment('lifecycle'), + + lifecycleName: computed('lifecycle', 'lifecycle.sidecar', function() { + if (this.lifecycle && this.lifecycle.sidecar) return 'sidecar'; + if (this.lifecycle && this.lifecycle.hook === 'prestart') return 'prestart'; + return 'main'; + }), + reservedMemory: attr('number'), reservedCPU: attr('number'), reservedDisk: attr('number'), diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index 9c2de8901..1bfd76d9b 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -16,6 +16,7 @@ @import './components/image-file.scss'; @import './components/inline-definitions'; @import './components/job-diff'; +@import './components/lifecycle-chart'; @import './components/loading-spinner'; @import './components/metrics'; @import './components/node-status-light'; diff --git a/ui/app/styles/components/lifecycle-chart.scss b/ui/app/styles/components/lifecycle-chart.scss new file mode 100644 index 000000000..3c009058a --- /dev/null +++ b/ui/app/styles/components/lifecycle-chart.scss @@ -0,0 +1,123 @@ +.lifecycle-chart { + padding-top: 2rem; + position: relative; + + .lifecycle-phases { + position: absolute; + top: 1.5em; + bottom: 1.5em; + right: 1.5em; + left: 1.5em; + + .divider { + position: absolute; + left: 25%; + height: 100%; + + stroke: $ui-gray-200; + stroke-width: 3px; + stroke-dasharray: 1, 7; + stroke-dashoffset: 1; + stroke-linecap: square; + } + } + + .lifecycle-phase { + position: absolute; + bottom: 0; + top: 0; + + border-top: 2px solid transparent; + + .name { + padding: 0.5rem 0.9rem; + font-size: $size-7; + font-weight: $weight-semibold; + color: $ui-gray-500; + } + + &.is-active { + background: $white-bis; + border-top: 2px solid $vagrant-blue; + + .name { + color: $vagrant-blue; + } + } + + &.prestart { + left: 0; + right: 75%; + } + + &.main { + left: 25%; + right: 0; + } + } + + .lifecycle-chart-rows { + margin-top: 2.5em; + } + + .lifecycle-chart-row { + position: relative; + + .task { + margin: 0.55em 0.9em; + padding: 0.3em 0.55em; + border: 1px solid $grey-blue; + border-radius: $radius; + background: white; + + .name { + font-weight: $weight-semibold; + + a { + color: inherit; + text-decoration: none; + } + } + + &:hover { + .name a { + text-decoration: underline; + } + } + + .lifecycle { + font-size: $size-7; + color: $ui-gray-400; + } + } + + &.is-active { + .task { + border-color: $nomad-green; + background: lighten($nomad-green, 50%); + + .lifecycle { + color: $ui-gray-500; + } + } + } + + &.is-finished { + .task { + color: $ui-gray-400; + } + } + + &.main { + margin-left: 25%; + } + + &.prestart { + margin-right: 75%; + } + + &:last-child .task { + margin-bottom: 0.9em; + } + } +} diff --git a/ui/app/styles/utils/structure-colors.scss b/ui/app/styles/utils/structure-colors.scss index 0ca8d8980..6ef72ebda 100644 --- a/ui/app/styles/utils/structure-colors.scss +++ b/ui/app/styles/utils/structure-colors.scss @@ -1,4 +1,6 @@ +$ui-gray-200: #dce0e6; $ui-gray-300: #bac1cc; +$ui-gray-400: #8e96a3; $ui-gray-500: #6f7682; $ui-gray-700: #525761; $ui-gray-800: #373a42; diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 04e21747b..1fcf2aa84 100644 --- a/ui/app/templates/allocations/allocation/index.hbs +++ b/ui/app/templates/allocations/allocation/index.hbs @@ -88,6 +88,8 @@ + {{lifecycle-chart taskStates=model.states}} +
Tasks diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index 488d246cc..568d41fe6 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -63,6 +63,10 @@ Driver {{model.task.driver}} + + Lifecycle + {{model.task.lifecycleName}} +
@@ -89,6 +93,38 @@ + {{#if (and (not model.task.lifecycle) prestartTaskStates)}} +
+
+ Prestart Tasks +
+
+ {{#list-table source=prestartTaskStates as |t|}} + {{#t.head}} + + Task + State + Lifecycle + {{/t.head}} + {{#t.body as |row|}} + + + {{#if (and row.model.isRunning (eq row.model.task.lifecycleName "prestart"))}} + + {{x-icon "warning" class="is-warning"}} + + {{/if}} + + {{row.model.task.name}} + {{row.model.state}} + {{row.model.task.lifecycleName}} + + {{/t.body}} + {{/list-table}} +
+
+ {{/if}} + {{#if network.ports.length}}
diff --git a/ui/app/templates/components/lifecycle-chart-row.hbs b/ui/app/templates/components/lifecycle-chart-row.hbs new file mode 100644 index 000000000..32bac26f2 --- /dev/null +++ b/ui/app/templates/components/lifecycle-chart-row.hbs @@ -0,0 +1,16 @@ +
+
+
+ {{#if taskState}} + {{#link-to "allocations.allocation.task" taskState.allocation taskState}} + {{task.name}} + {{/link-to}} + {{else}} + {{task.name}} + {{/if}} +
+
{{capitalize task.lifecycleName}} Task
+
+
diff --git a/ui/app/templates/components/lifecycle-chart.hbs b/ui/app/templates/components/lifecycle-chart.hbs new file mode 100644 index 000000000..864161389 --- /dev/null +++ b/ui/app/templates/components/lifecycle-chart.hbs @@ -0,0 +1,33 @@ + {{#if (gt lifecyclePhases.length 1)}} +
+
+ Task Lifecycle {{if taskStates "Status" "Configuration"}} +
+
+ +
+ {{#each lifecyclePhases as |phase|}} +
+
{{phase.name}}
+
+ {{/each}} + + + +
+ +
+ {{#if tasks}} + {{#each sortedLifecycleTasks as |task|}} + {{lifecycle-chart-row task=task}} + {{/each}} + {{else}} + {{#each sortedLifecycleTaskStates as |state|}} + {{lifecycle-chart-row taskState=state task=state.task}} + {{/each}} + {{/if}} +
+ +
+
+ {{/if}} diff --git a/ui/app/templates/jobs/job/task-group.hbs b/ui/app/templates/jobs/job/task-group.hbs index c4e3f66b8..cde27dd97 100644 --- a/ui/app/templates/jobs/job/task-group.hbs +++ b/ui/app/templates/jobs/job/task-group.hbs @@ -112,6 +112,8 @@
+ {{lifecycle-chart tasks=model.tasks}} + {{#if model.volumes.length}}
diff --git a/ui/mirage/factories/task.js b/ui/mirage/factories/task.js index 598175766..03ac9270b 100644 --- a/ui/mirage/factories/task.js +++ b/ui/mirage/factories/task.js @@ -17,4 +17,16 @@ export default Factory.extend({ driver: () => faker.helpers.randomize(DRIVERS), Resources: generateResources, + + Lifecycle: i => { + const cycle = i % 3; + + if (cycle === 0) { + return null; + } else if (cycle === 1) { + return { Hook: 'prestart', Sidecar: false }; + } else { + return { Hook: 'prestart', Sidecar: true }; + } + }, }); diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js index 25e017d59..0ff103c6b 100644 --- a/ui/tests/acceptance/allocation-detail-test.js +++ b/ui/tests/acceptance/allocation-detail-test.js @@ -72,6 +72,111 @@ module('Acceptance | allocation detail', function(hooks) { assert.equal(Allocation.resourceCharts.objectAt(1).name, 'Memory', 'Second chart is Memory'); }); + test('/allocation/:id should present task lifecycles', async function(assert) { + const job = server.create('job', { + groupsCount: 1, + groupTaskCount: 3, + withGroupServices: true, + createAllocations: false, + }); + + const allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', { + clientStatus: 'running', + jobId: job.id, + }); + + const taskStatePhases = server.db.taskStates.where({ allocationId: allocation.id }).reduce( + (phases, state) => { + const lifecycle = server.db.tasks.findBy({ name: state.name }).Lifecycle; + + if (lifecycle) { + if (lifecycle.Sidecar) { + phases.sidecars.push(state); + state.lifecycleString = 'Sidecar'; + } else { + phases.prestarts.push(state); + state.lifecycleString = 'Prestart'; + } + } else { + phases.mains.push(state); + state.lifecycleString = 'Main'; + } + + return phases; + }, + { + prestarts: [], + sidecars: [], + mains: [], + } + ); + + taskStatePhases.prestarts = taskStatePhases.prestarts.sortBy('name'); + taskStatePhases.sidecars = taskStatePhases.sidecars.sortBy('name'); + taskStatePhases.mains = taskStatePhases.mains.sortBy('name'); + + const sortedServerStates = taskStatePhases.prestarts.concat( + taskStatePhases.sidecars, + taskStatePhases.mains + ); + + await Allocation.visit({ id: allocation.id }); + + assert.ok(Allocation.lifecycleChart.isPresent); + assert.equal(Allocation.lifecycleChart.title, 'Task Lifecycle Status'); + assert.equal(Allocation.lifecycleChart.phases.length, 2); + assert.equal(Allocation.lifecycleChart.tasks.length, sortedServerStates.length); + + const stateActiveIterator = state => state.state === 'running'; + const anyPrestartsActive = taskStatePhases.prestarts.some(stateActiveIterator); + + if (anyPrestartsActive) { + assert.ok(Allocation.lifecycleChart.phases[0].isActive); + } else { + assert.notOk(Allocation.lifecycleChart.phases[0].isActive); + } + + const anyMainsActive = taskStatePhases.mains.some(stateActiveIterator); + + if (anyMainsActive) { + assert.ok(Allocation.lifecycleChart.phases[1].isActive); + } else { + assert.notOk(Allocation.lifecycleChart.phases[1].isActive); + } + + Allocation.lifecycleChart.tasks.forEach((Task, index) => { + const serverState = sortedServerStates[index]; + + assert.equal(Task.name, serverState.name); + + if (serverState.lifecycleString === 'Sidecar') { + assert.ok(Task.isSidecar); + } else if (serverState.lifecycleString === 'Prestart') { + assert.ok(Task.isPrestart); + } else { + assert.ok(Task.isMain); + } + + assert.equal(Task.lifecycle, `${serverState.lifecycleString} Task`); + + if (serverState.state === 'running') { + assert.ok(Task.isActive); + } else { + assert.notOk(Task.isActive); + } + + // Task state factory uses invalid dates for tasks that aren’t finished + if (isNaN(serverState.finishedAt)) { + assert.notOk(Task.isFinished); + } else { + assert.ok(Task.isFinished); + } + }); + + await Allocation.lifecycleChart.tasks[0].visit(); + assert.equal(currentURL(), `/allocations/${allocation.id}/${sortedServerStates[0].name}`); + }); + test('/allocation/:id should list all tasks for the allocation', async function(assert) { assert.equal( Allocation.tasks.length, diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index 3d8797d73..d9d219d06 100644 --- a/ui/tests/acceptance/task-detail-test.js +++ b/ui/tests/acceptance/task-detail-test.js @@ -31,6 +31,10 @@ module('Acceptance | task detail', function(hooks) { 'Task started at' ); + const lifecycle = server.db.tasks.where({ name: task.name })[0].Lifecycle; + const prestartString = lifecycle && lifecycle.Sidecar ? 'sidecar' : 'prestart'; + assert.equal(Task.lifecycle, lifecycle ? prestartString : 'main'); + assert.equal(document.title, `Task ${task.name} - Nomad`); }); @@ -92,6 +96,86 @@ module('Acceptance | task detail', function(hooks) { assert.equal(Task.resourceCharts.objectAt(1).name, 'Memory', 'Second chart is Memory'); }); + test('/allocation/:id/:task_name lists related prestart tasks for a main task when they exist', async function(assert) { + const job = server.create('job', { + groupsCount: 2, + groupTaskCount: 3, + createAllocations: false, + status: 'running', + }); + + job.task_group_ids.forEach(taskGroupId => { + server.create('allocation', { + jobId: job.id, + taskGroup: server.db.taskGroups.find(taskGroupId).name, + forceRunningClientStatus: true, + }); + }); + + const taskGroup = job.task_groups.models[0]; + const [mainTask, sidecarTask, prestartTask] = taskGroup.tasks.models; + + mainTask.attrs.Lifecycle = null; + mainTask.save(); + + sidecarTask.attrs.Lifecycle = { Sidecar: true, Hook: 'prestart' }; + sidecarTask.save(); + + prestartTask.attrs.Lifecycle = { Sidecar: false, Hook: 'prestart' }; + prestartTask.save(); + + taskGroup.save(); + + const noPrestartTasksTaskGroup = job.task_groups.models[1]; + noPrestartTasksTaskGroup.tasks.models.forEach(task => { + task.attrs.Lifecycle = null; + task.save(); + }); + + const mainTaskState = server.schema.taskStates.findBy({ name: mainTask.name }); + const sidecarTaskState = server.schema.taskStates.findBy({ name: sidecarTask.name }); + const prestartTaskState = server.schema.taskStates.findBy({ name: prestartTask.name }); + + prestartTaskState.attrs.state = 'running'; + prestartTaskState.attrs.finishedAt = null; + prestartTaskState.save(); + + await Task.visit({ id: mainTaskState.allocationId, name: mainTask.name }); + + assert.ok(Task.hasPrestartTasks); + assert.equal(Task.prestartTasks.length, 2); + + Task.prestartTasks[0].as(SidecarTask => { + assert.equal(SidecarTask.name, sidecarTask.name); + assert.equal(SidecarTask.state, sidecarTaskState.state); + assert.equal(SidecarTask.lifecycle, 'sidecar'); + assert.notOk(SidecarTask.isBlocking); + }); + + Task.prestartTasks[1].as(PrestartTask => { + assert.equal(PrestartTask.name, prestartTask.name); + assert.equal(PrestartTask.state, prestartTaskState.state); + assert.equal(PrestartTask.lifecycle, 'prestart'); + assert.ok(PrestartTask.isBlocking); + }); + + await Task.visit({ id: sidecarTaskState.allocationId, name: sidecarTask.name }); + + assert.notOk(Task.hasPrestartTasks); + + const noPrestartTasksTask = noPrestartTasksTaskGroup.tasks.models[0]; + const noPrestartTasksTaskState = server.db.taskStates.findBy({ + name: noPrestartTasksTask.name, + }); + + await Task.visit({ + id: noPrestartTasksTaskState.allocationId, + name: noPrestartTasksTaskState.name, + }); + + assert.notOk(Task.hasPrestartTasks); + }); + test('the addresses table lists all reserved and dynamic ports', async function(assert) { const taskResources = allocation.taskResourceIds .map(id => server.db.taskResources.find(id)) diff --git a/ui/tests/acceptance/task-group-detail-test.js b/ui/tests/acceptance/task-group-detail-test.js index c3001aa5f..8b7761526 100644 --- a/ui/tests/acceptance/task-group-detail-test.js +++ b/ui/tests/acceptance/task-group-detail-test.js @@ -241,6 +241,34 @@ module('Acceptance | task group detail', function(hooks) { assert.notOk(normalRow.rescheduled, 'Normal row has no reschedule icon'); }); + test('/jobs/:id/:task-group should present task lifecycles', async function(assert) { + job = server.create('job', { + groupsCount: 2, + groupTaskCount: 3, + }); + + const taskGroups = server.db.taskGroups.where({ jobId: job.id }); + taskGroup = taskGroups[0]; + + await TaskGroup.visit({ id: job.id, name: taskGroup.name }); + + assert.ok(TaskGroup.lifecycleChart.isPresent); + assert.equal(TaskGroup.lifecycleChart.title, 'Task Lifecycle Configuration'); + + tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id)); + const taskNames = tasks.mapBy('name'); + + // This is thoroughly tested in allocation detail tests, so this mostly checks what’s different + + assert.equal(TaskGroup.lifecycleChart.tasks.length, 3); + + TaskGroup.lifecycleChart.tasks.forEach(Task => { + assert.ok(taskNames.includes(Task.name)); + assert.notOk(Task.isActive); + assert.notOk(Task.isFinished); + }); + }); + test('when the task group depends on volumes, the volumes table is shown', async function(assert) { await TaskGroup.visit({ id: job.id, name: taskGroup.name }); diff --git a/ui/tests/integration/components/lifecycle-chart-test.js b/ui/tests/integration/components/lifecycle-chart-test.js new file mode 100644 index 000000000..85475d9eb --- /dev/null +++ b/ui/tests/integration/components/lifecycle-chart-test.js @@ -0,0 +1,101 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, settled } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { create } from 'ember-cli-page-object'; +import LifecycleChart from 'nomad-ui/tests/pages/components/lifecycle-chart'; + +const Chart = create(LifecycleChart); + +const tasks = [ + { + lifecycleName: 'main', + name: 'main two', + }, + { + lifecycleName: 'main', + name: 'main one', + }, + { + lifecycleName: 'prestart', + name: 'prestart', + }, + { + lifecycleName: 'sidecar', + name: 'sidecar', + }, +]; + +module('Integration | Component | lifecycle-chart', function(hooks) { + setupRenderingTest(hooks); + + test('it renders stateless phases and lifecycle- and name-sorted tasks', async function(assert) { + this.set('tasks', tasks); + + await render(hbs`{{lifecycle-chart tasks=tasks}}`); + assert.ok(Chart.isPresent); + + assert.equal(Chart.phases[0].name, 'Prestart'); + assert.equal(Chart.phases[1].name, 'Main'); + + Chart.phases.forEach(phase => assert.notOk(phase.isActive)); + + assert.deepEqual(Chart.tasks.mapBy('name'), ['prestart', 'sidecar', 'main one', 'main two']); + assert.deepEqual(Chart.tasks.mapBy('lifecycle'), [ + 'Prestart Task', + 'Sidecar Task', + 'Main Task', + 'Main Task', + ]); + + assert.ok(Chart.tasks[0].isPrestart); + assert.ok(Chart.tasks[1].isSidecar); + assert.ok(Chart.tasks[2].isMain); + + Chart.tasks.forEach(task => { + assert.notOk(task.isActive); + assert.notOk(task.isFinished); + }); + }); + + test('it doesn’t render when there’s only one phase', async function(assert) { + this.set('tasks', [ + { + lifecycleName: 'main', + }, + ]); + + await render(hbs`{{lifecycle-chart tasks=tasks}}`); + assert.notOk(Chart.isPresent); + }); + + test('it reflects phase and task states when states are passed in', async function(assert) { + this.set( + 'taskStates', + tasks.map(task => { + return { task }; + }) + ); + + await render(hbs`{{lifecycle-chart taskStates=taskStates}}`); + assert.ok(Chart.isPresent); + + Chart.phases.forEach(phase => assert.notOk(phase.isActive)); + + Chart.tasks.forEach(task => { + assert.notOk(task.isActive); + assert.notOk(task.isFinished); + }); + + this.set('taskStates.firstObject.state', 'running'); + await settled(); + + assert.ok(Chart.phases[1].isActive); + assert.ok(Chart.tasks[3].isActive); + + this.set('taskStates.firstObject.finishedAt', new Date()); + await settled(); + + assert.ok(Chart.tasks[3].isFinished); + }); +}); diff --git a/ui/tests/pages/allocations/detail.js b/ui/tests/pages/allocations/detail.js index f0ddf45f5..74eabd2f0 100644 --- a/ui/tests/pages/allocations/detail.js +++ b/ui/tests/pages/allocations/detail.js @@ -10,6 +10,7 @@ import { import allocations from 'nomad-ui/tests/pages/components/allocations'; import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button'; +import LifecycleChart from 'nomad-ui/tests/pages/components/lifecycle-chart'; export default create({ visit: visitable('/allocations/:id'), @@ -36,6 +37,8 @@ export default create({ resourceEmptyMessage: text('[data-test-resource-error-headline]'), + lifecycleChart: LifecycleChart, + tasks: collection('[data-test-task-row]', { name: text('[data-test-name]'), state: text('[data-test-state]'), diff --git a/ui/tests/pages/allocations/task/detail.js b/ui/tests/pages/allocations/task/detail.js index 70c1ffb7e..27f4e3c76 100644 --- a/ui/tests/pages/allocations/task/detail.js +++ b/ui/tests/pages/allocations/task/detail.js @@ -21,9 +21,11 @@ export default create({ }, }, - state: text('[data-test-state]'), + state: text('.title [data-test-state]'), startedAt: text('[data-test-started-at]'), + lifecycle: text('.pair [data-test-lifecycle]'), + restart: twoStepButton('[data-test-restart]'), execButton: { @@ -47,6 +49,14 @@ export default create({ resourceEmptyMessage: text('[data-test-resource-error-headline]'), + hasPrestartTasks: isPresent('[data-test-prestart-tasks]'), + prestartTasks: collection('[data-test-prestart-task]', { + name: text('[data-test-name]'), + state: text('[data-test-state]'), + lifecycle: text('[data-test-lifecycle]'), + isBlocking: isPresent('.icon-is-warning'), + }), + hasAddresses: isPresent('[data-test-task-addresses]'), addresses: collection('[data-test-task-address]', { name: text('[data-test-task-address-name]'), diff --git a/ui/tests/pages/components/lifecycle-chart.js b/ui/tests/pages/components/lifecycle-chart.js new file mode 100644 index 000000000..1733849b7 --- /dev/null +++ b/ui/tests/pages/components/lifecycle-chart.js @@ -0,0 +1,27 @@ +import { clickable, collection, hasClass, text } from 'ember-cli-page-object'; + +export default { + scope: '[data-test-lifecycle-chart]', + + title: text('.boxed-section-head'), + + phases: collection('[data-test-lifecycle-phase]', { + name: text('[data-test-name]'), + + isActive: hasClass('is-active'), + }), + + tasks: collection('[data-test-lifecycle-task]', { + name: text('[data-test-name]'), + lifecycle: text('[data-test-lifecycle]'), + + isActive: hasClass('is-active'), + isFinished: hasClass('is-finished'), + + isMain: hasClass('main'), + isPrestart: hasClass('prestart'), + isSidecar: hasClass('sidecar'), + + visit: clickable('a'), + }), +}; diff --git a/ui/tests/pages/jobs/job/task-group.js b/ui/tests/pages/jobs/job/task-group.js index d3d6b048d..19f120f1f 100644 --- a/ui/tests/pages/jobs/job/task-group.js +++ b/ui/tests/pages/jobs/job/task-group.js @@ -12,6 +12,7 @@ import { import allocations from 'nomad-ui/tests/pages/components/allocations'; import error from 'nomad-ui/tests/pages/components/error'; import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select'; +import LifecycleChart from 'nomad-ui/tests/pages/components/lifecycle-chart'; export default create({ pageSize: 25, @@ -39,6 +40,8 @@ export default create({ isEmpty: isPresent('[data-test-empty-allocations-list]'), + lifecycleChart: LifecycleChart, + hasVolumes: isPresent('[data-test-volumes]'), volumes: collection('[data-test-volumes] [data-test-volume]', { name: text('[data-test-volume-name]'),