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.
This commit is contained in:
Jasmine Dahilig 2020-04-30 06:15:19 -07:00 committed by GitHub
parent a7a64443e1
commit a9004faa11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 700 additions and 2 deletions

View File

@ -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)]

View File

@ -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';
}
}),
});

View File

@ -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}`;
}

View File

@ -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') || [])

View File

@ -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'),
});

View File

@ -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'),

View File

@ -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';

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -88,6 +88,8 @@
</div>
</div>
{{lifecycle-chart taskStates=model.states}}
<div class="boxed-section">
<div class="boxed-section-head">
Tasks

View File

@ -63,6 +63,10 @@
<span class="term">Driver</span>
{{model.task.driver}}
</span>
<span class="pair">
<span class="term">Lifecycle</span>
<span data-test-lifecycle>{{model.task.lifecycleName}}</span>
</span>
</div>
</div>
@ -89,6 +93,38 @@
</div>
</div>
{{#if (and (not model.task.lifecycle) prestartTaskStates)}}
<div class="boxed-section" data-test-prestart-tasks>
<div class="boxed-section-head">
Prestart Tasks
</div>
<div class="boxed-section-body is-full-bleed">
{{#list-table source=prestartTaskStates as |t|}}
{{#t.head}}
<th class="is-narrow"></th>
<th>Task</th>
<th>State</th>
<th>Lifecycle</th>
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-prestart-task>
<td class="is-narrow">
{{#if (and row.model.isRunning (eq row.model.task.lifecycleName "prestart"))}}
<span class="tooltip text-center" role="tooltip" aria-label="Lifecycle constraints not met">
{{x-icon "warning" class="is-warning"}}
</span>
{{/if}}
</td>
<td data-test-name>{{row.model.task.name}}</td>
<td data-test-state>{{row.model.state}}</td>
<td data-test-lifecycle>{{row.model.task.lifecycleName}}</td>
</tr>
{{/t.body}}
{{/list-table}}
</div>
</div>
{{/if}}
{{#if network.ports.length}}
<div class="boxed-section" data-test-task-addresses>
<div class="boxed-section-head">

View File

@ -0,0 +1,16 @@
<div
class="lifecycle-chart-row {{task.lifecycleName}} {{activeClass}} {{finishedClass}}"
data-test-lifecycle-task>
<div class="task">
<div class="name" data-test-name>
{{#if taskState}}
{{#link-to "allocations.allocation.task" taskState.allocation taskState}}
{{task.name}}
{{/link-to}}
{{else}}
{{task.name}}
{{/if}}
</div>
<div class="lifecycle" data-test-lifecycle>{{capitalize task.lifecycleName}} Task</div>
</div>
</div>

View File

@ -0,0 +1,33 @@
{{#if (gt lifecyclePhases.length 1)}}
<div class="boxed-section" data-test-lifecycle-chart>
<div class="boxed-section-head">
Task Lifecycle {{if taskStates "Status" "Configuration"}}
</div>
<div class="boxed-section-body lifecycle-chart">
<div class="lifecycle-phases">
{{#each lifecyclePhases as |phase|}}
<div class="lifecycle-phase {{if phase.isActive "is-active"}} {{if (eq phase.name "Main") "main" "prestart"}}" data-test-lifecycle-phase>
<div class="name" data-test-name>{{phase.name}}</div>
</div>
{{/each}}
<svg class="divider">
<line x1="0" y1="0" x2="0" y2="100%" />
</svg>
</div>
<div class="lifecycle-chart-rows">
{{#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}}
</div>
</div>
</div>
{{/if}}

View File

@ -112,6 +112,8 @@
</div>
</div>
{{lifecycle-chart tasks=model.tasks}}
{{#if model.volumes.length}}
<div data-test-volumes class="boxed-section">
<div class="boxed-section-head">

View File

@ -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 };
}
},
});

View File

@ -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 arent 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,

View File

@ -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))

View File

@ -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 whats 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 });

View File

@ -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 doesnt render when theres 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);
});
});

View File

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

View File

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

View File

@ -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'),
}),
};

View File

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