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}}
+
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]'),