Merge pull request #3472 from hashicorp/f-ui-task-hierarchy

Task route hierarchy in the UI
This commit is contained in:
Michael Lange 2017-11-14 11:15:32 -08:00 committed by GitHub
commit e8bb3318ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 564 additions and 195 deletions

View File

@ -1,17 +1,5 @@
import Ember from 'ember';
import Sortable from 'nomad-ui/mixins/sortable';
const { Controller, computed } = Ember;
const { Controller } = Ember;
export default Controller.extend(Sortable, {
queryParams: {
sortProperty: 'sort',
sortDescending: 'desc',
},
sortProperty: 'name',
sortDescending: false,
listToSort: computed.alias('model.states'),
sortedStates: computed.alias('listSorted'),
});
export default Controller.extend({});

View File

@ -0,0 +1,17 @@
import Ember from 'ember';
import Sortable from 'nomad-ui/mixins/sortable';
const { Controller, computed } = Ember;
export default Controller.extend(Sortable, {
queryParams: {
sortProperty: 'sort',
sortDescending: 'desc',
},
sortProperty: 'name',
sortDescending: false,
listToSort: computed.alias('model.states'),
sortedStates: computed.alias('listSorted'),
});

View File

@ -0,0 +1,23 @@
import Ember from 'ember';
const { Controller, computed } = Ember;
export default Controller.extend({
network: computed.alias('model.resources.networks.firstObject'),
ports: computed('network.reservedPorts.[]', 'network.dynamicPorts.[]', function() {
return this.get('network.reservedPorts')
.map(port => ({
name: port.Label,
port: port.Value,
isDynamic: false,
}))
.concat(
this.get('network.dynamicPorts').map(port => ({
name: port.Label,
port: port.Value,
isDynamic: true,
}))
)
.sortBy('name');
}),
});

View File

@ -38,6 +38,18 @@ export default Model.extend({
return STATUS_ORDER[this.get('clientStatus')] || 100;
}),
statusClass: computed('clientStatus', function() {
const classMap = {
pending: 'is-pending',
running: 'is-primary',
complete: 'is-complete',
failed: 'is-error',
lost: 'is-light',
};
return classMap[this.get('clientStatus')] || 'is-dark';
}),
taskGroup: computed('taskGroupName', 'job.taskGroups.[]', function() {
const taskGroups = this.get('job.taskGroups');
return taskGroups && taskGroups.findBy('name', this.get('taskGroupName'));

View File

@ -22,4 +22,15 @@ export default Fragment.extend({
resources: fragment('resources'),
events: fragmentArray('task-event'),
stateClass: computed('state', function() {
const classMap = {
pending: 'is-pending',
running: 'is-primary',
finished: 'is-complete',
failed: 'is-error',
};
return classMap[this.get('state')] || 'is-dark';
}),
});

View File

@ -25,7 +25,11 @@ Router.map(function() {
});
this.route('allocations', function() {
this.route('allocation', { path: '/:allocation_id' });
this.route('allocation', { path: '/:allocation_id' }, function() {
this.route('task', { path: '/:name' }, function() {
this.route('logs');
});
});
});
this.route('settings', function() {

View File

@ -0,0 +1,22 @@
import Ember from 'ember';
const { Route, inject, Error: EmberError } = Ember;
export default Route.extend({
store: inject.service(),
model({ name }) {
const allocation = this.modelFor('allocations.allocation');
if (allocation) {
const task = allocation.get('states').findBy('name', name);
if (task) {
return task;
}
const err = new EmberError(`Task ${name} not found for allocation ${allocation.get('id')}`);
err.code = '404';
this.controllerFor('application').set('error', err);
}
},
});

View File

@ -4,11 +4,6 @@
opacity: 0.7;
text-decoration: none;
&:hover {
color: $primary-invert;
opacity: 1;
}
+ .breadcrumb {
margin-left: 15px;
&::before {
@ -23,4 +18,9 @@
opacity: 1;
}
}
a.breadcrumb:hover {
color: $primary-invert;
opacity: 1;
}
}

View File

@ -16,6 +16,16 @@
color: $blue-invert;
}
&.is-primary {
background: $primary;
color: $primary-invert;
}
&.is-complete {
background: $nomad-green-dark;
color: findColorInvert($nomad-green-dark);
}
&.is-error {
background: $danger;
color: $danger-invert;

View File

@ -1,8 +1,3 @@
<div class="page-layout">
{{#global-header class="page-header"}}
Allocations
{{/global-header}}
{{#gutter-menu class="page-body"}}
{{outlet}}
{{/gutter-menu}}
{{outlet}}
</div>

View File

@ -1,91 +1 @@
<section class="section">
<h1 class="title">Allocation {{model.name}}</h1>
<h3 class="subtitle">
For job {{#link-to "jobs.job" model.job (query-params jobNamespace=model.job.namespace.id)}}{{model.job.name}}{{/link-to}}
on client {{#link-to "clients.client" model.node}}{{model.node.shortId}}{{/link-to}}
</h3>
<div class="message">
<div class="message-header">
Tasks
</div>
{{#list-table
source=sortedStates
sortProperty=sortProperty
sortDescending=sortDescending
class="is-striped tasks" as |t|}}
{{#t.head}}
{{#t.sort-by prop="name"}}Name{{/t.sort-by}}
{{#t.sort-by prop="state"}}State{{/t.sort-by}}
<th>Last Event</th>
{{#t.sort-by prop="events.lastObject.time"}}Time{{/t.sort-by}}
<th>Addresses</th>
{{/t.head}}
{{#t.body as |row|}}
<tr>
<td>{{row.model.task.name}}</td>
<td>{{row.model.state}}</td>
<td>
{{#if row.model.events.lastObject.displayMessage}}
{{row.model.events.lastObject.displayMessage}}
{{else}}
<em>No message</em>
{{/if}}
</td>
<td>{{moment-format row.model.events.lastObject.time "MM/DD/YY HH:mm:ss [UTC]"}}</td>
<td>
<ul>
{{#each row.model.resources.networks.firstObject.reservedPorts as |port|}}
<li>
<strong>{{port.Label}}:</strong>
<a href="http://{{row.model.allocation.node.address}}:{{port.Value}}" target="_blank">{{row.model.allocation.node.address}}:{{port.Value}}</a>
</li>
{{/each}}
{{#each row.model.resources.networks.firstObject.dynamicPorts as |port|}}
<li>
<strong>{{port.Label}}:</strong>
<a href="http://{{row.model.allocation.node.address}}:{{port.Value}}" target="_blank">{{row.model.allocation.node.address}}:{{port.Value}}</a>
</li>
{{/each}}
</ul>
</td>
</tr>
{{/t.body}}
{{/list-table}}
</div>
{{#each model.states as |state|}}
<div class="message task-state-events">
<div class="message-header">
{{state.task.name}} ({{state.state}}) Started: {{moment-format state.startedAt "MM/DD/YY HH:mm:ss [UTC]"}}
{{#unless state.isActive}}
Ended: {{moment-format state.finishedAt "MM/DD/YY HH:mm:ss [UTC]"}}
{{/unless}}
</div>
<table class="table is-striped task-events">
<thead>
<tr>
<td class="is-3">Time</td>
<td class="is-1">Type</td>
<td>Description</td>
</tr>
</thead>
<tbody>
{{#each (reverse state.events) as |event|}}
<tr>
<td>{{moment-format event.time "MM/DD/YY HH:mm:ss [UTC]"}}</td>
<td>{{event.type}}</td>
<td>
{{#if event.displayMessage}}
{{event.displayMessage}}
{{else}}
<em>No message</em>
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/each}}
</section>
{{outlet}}

View File

@ -0,0 +1,82 @@
{{#global-header class="page-header"}}
<span class="breadcrumb">Allocations</span>
{{#link-to "allocations.allocation" model class="breadcrumb"}}
{{model.shortId}}
{{/link-to}}
{{/global-header}}
{{#gutter-menu class="page-body"}}
<section class="section">
<h1 class="title">
Allocation {{model.name}}
<span class="bumper-left tag {{model.statusClass}}">{{model.clientStatus}}</span>
<span class="tag is-hollow is-small no-text-transform">{{model.id}}</span>
</h1>
<div class="boxed-section is-small">
<div class="boxed-section-body inline-definitions">
<span class="label">Allocation Details</span>
<span class="pair job-link"><span class="term">Job</span>
{{#link-to "jobs.job" model.job (query-params jobNamespace=model.job.namespace.id)}}{{model.job.name}}{{/link-to}}
</span>
<span class="pair node-link"><span class="term">Client</span>
{{#link-to "clients.client" model.node}}{{model.node.shortId}}{{/link-to}}
</span>
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Tasks
</div>
<div class="boxed-section-body is-full-bleed">
{{#list-table
source=sortedStates
sortProperty=sortProperty
sortDescending=sortDescending
class="is-striped tasks" as |t|}}
{{#t.head}}
{{#t.sort-by prop="name"}}Name{{/t.sort-by}}
{{#t.sort-by prop="state"}}State{{/t.sort-by}}
<th>Last Event</th>
{{#t.sort-by prop="events.lastObject.time"}}Time{{/t.sort-by}}
<th>Addresses</th>
{{/t.head}}
{{#t.body as |row|}}
<tr>
<td>
{{#link-to "allocations.allocation.task" row.model.allocation row.model}}
{{row.model.task.name}}
{{/link-to}}
</td>
<td>{{row.model.state}}</td>
<td>
{{#if row.model.events.lastObject.displayMessage}}
{{row.model.events.lastObject.displayMessage}}
{{else}}
<em>No message</em>
{{/if}}
</td>
<td>{{moment-format row.model.events.lastObject.time "MM/DD/YY HH:mm:ss [UTC]"}}</td>
<td>
<ul>
{{#each row.model.resources.networks.firstObject.reservedPorts as |port|}}
<li>
<strong>{{port.Label}}:</strong>
<a href="http://{{row.model.allocation.node.address}}:{{port.Value}}" target="_blank">{{row.model.allocation.node.address}}:{{port.Value}}</a>
</li>
{{/each}}
{{#each row.model.resources.networks.firstObject.dynamicPorts as |port|}}
<li>
<strong>{{port.Label}}:</strong>
<a href="http://{{row.model.allocation.node.address}}:{{port.Value}}" target="_blank">{{row.model.allocation.node.address}}:{{port.Value}}</a>
</li>
{{/each}}
</ul>
</td>
</tr>
{{/t.body}}
{{/list-table}}
</div>
</div>
</section>
{{/gutter-menu}}

View File

@ -0,0 +1 @@
{{outlet}}

View File

@ -0,0 +1,94 @@
{{#global-header class="page-header"}}
<span class="breadcrumb">Allocations</span>
{{#link-to "allocations.allocation" model.allocation class="breadcrumb"}}
{{model.allocation.shortId}}
{{/link-to}}
{{#link-to "allocations.allocation.task" model.allocation model class="breadcrumb"}}
{{model.name}}
{{/link-to}}
{{/global-header}}
{{#gutter-menu class="page-body"}}
{{partial "allocations/allocation/task/subnav"}}
<section class="section">
<h1 class="title">
{{model.name}}
<span class="bumper-left tag {{model.stateClass}}">{{model.state}}</span>
</h1>
<div class="boxed-section is-small">
<div class="boxed-section-body inline-definitions">
<span class="label">Task Details</span>
<span class="pair">
<span class="term">Started At</span>
{{moment-format model.startedAt "MM/DD/YY HH:mm:ss"}}
</span>
{{#if model.finishedAt}}
<span class="pair">
<span class="term">Finished At</span>
{{moment-format model.finishedAt "MM/DD/YY HH:mm:ss"}}
</span>
{{/if}}
<span class="pair">
<span class="term">Driver</span>
{{model.task.driver}}
</span>
</div>
</div>
{{#if ports.length}}
<div class="boxed-section">
<div class="boxed-section-head">
Addresses
</div>
<div class="boxed-section-body is-full-bleed">
{{#list-table source=ports class="addresses-list" as |t|}}
{{#t.head}}
<th class="is-1">Dynamic?</th>
<th class="is-2">Name</th>
<th>Address</th>
{{/t.head}}
{{#t.body as |row|}}
<tr>
<td>{{if row.model.isDynamic "Yes" "No"}}</td>
<td>{{row.model.name}}</td>
<td>
<a href="http://{{model.allocation.node.address}}:{{row.model.port}}" target="_blank">
{{model.allocation.node.address}}:{{row.model.port}}
</a>
</td>
</tr>
{{/t.body}}
{{/list-table}}
</div>
</div>
{{/if}}
<div class="boxed-section">
<div class="boxed-section-head">
Recent Events
</div>
<div class="boxed-section-body is-full-bleed">
{{#list-table source=(reverse model.events) class="is-striped task-events" as |t|}}
{{#t.head}}
<th class="is-3">Time</th>
<th class="is-1">Type</th>
<th>Description</th>
{{/t.head}}
{{#t.body as |row|}}
<tr>
<td>{{moment-format row.model.time "MM/DD/YY HH:mm:ss"}}</td>
<td>{{row.model.type}}</td>
<td>
{{#if row.model.displayMessage}}
{{row.model.displayMessage}}
{{else}}
<em>No message</em>
{{/if}}
</td>
</tr>
{{/t.body}}
{{/list-table}}
</div>
</div>
</section>
{{/gutter-menu}}

View File

@ -0,0 +1,5 @@
<div class="tabs is-subnav">
<ul>
<li>{{#link-to "allocations.allocation.task.index" model.allocation model activeClass="is-active"}}Overview{{/link-to}}</li>
</ul>
</div>

View File

@ -41,8 +41,19 @@ export function generateNetworks(options = {}) {
MBits: 10,
ReservedPorts: Array(
faker.random.number({
min: options.minPorts || 0,
max: options.maxPorts || 3,
min: options.minPorts != null ? options.minPorts : 0,
max: options.maxPorts != null ? options.maxPorts : 2,
})
)
.fill(null)
.map(() => ({
Label: faker.hacker.noun(),
Value: faker.random.number({ min: 5000, max: 60000 }),
})),
DynamicPorts: Array(
faker.random.number({
min: options.minPorts != null ? options.minPorts : 0,
max: options.maxPorts != null ? options.maxPorts : 2,
})
)
.fill(null)

View File

@ -36,6 +36,24 @@ export default Factory.extend({
},
}),
withoutTaskWithPorts: trait({
afterCreate(allocation, server) {
const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup });
const resources = taskGroup.taskIds.map(id =>
server.create(
'task-resources',
{
allocation,
name: server.db.tasks.find(id).name,
},
'withoutReservedPorts'
)
);
allocation.update({ taskResourcesIds: resources.mapBy('id') });
},
}),
afterCreate(allocation, server) {
Ember.assert(
'[Mirage] No jobs! make sure jobs are created before allocations',

View File

@ -9,4 +9,8 @@ export default Factory.extend({
withReservedPorts: trait({
resources: () => generateResources({ networks: { minPorts: 1 } }),
}),
withoutReservedPorts: trait({
resources: () => generateResources({ networks: { minPorts: 0, maxPorts: 0 } }),
}),
});

View File

@ -28,14 +28,19 @@ test('/allocation/:id should name the allocation and link to the corresponding j
assert
) {
assert.ok(find('h1').textContent.includes(allocation.name), 'Allocation name is in the heading');
assert.ok(find('h3').textContent.includes(job.name), 'Job name is in the subheading');
assert.ok(
find('h3').textContent.includes(node.id.split('-')[0]),
assert.equal(
find('.inline-definitions .job-link a').textContent.trim(),
job.name,
'Job name is in the subheading'
);
assert.equal(
find('.inline-definitions .node-link a').textContent.trim(),
node.id.split('-')[0],
'Node short id is in the subheading'
);
andThen(() => {
click(findAll('h3 a')[0]);
click('.inline-definitions .job-link a');
});
andThen(() => {
@ -45,7 +50,7 @@ test('/allocation/:id should name the allocation and link to the corresponding j
visit(`/allocations/${allocation.id}`);
andThen(() => {
click(findAll('h3 a')[1]);
click('.inline-definitions .node-link a');
});
andThen(() => {
@ -67,6 +72,7 @@ test('each task row should list high-level information for the task', function(a
.map(id => server.db.taskResources.find(id))
.sortBy('name')[0];
const reservedPorts = taskResources.resources.Networks[0].ReservedPorts;
const dynamicPorts = taskResources.resources.Networks[0].DynamicPorts;
const taskRow = $(findAll('.tasks tbody tr')[0]);
const events = server.db.taskEvents.where({ taskStateId: task.id });
const event = events[events.length - 1];
@ -105,83 +111,17 @@ test('each task row should list high-level information for the task', function(a
);
assert.ok(reservedPorts.length, 'The task has reserved ports');
assert.ok(dynamicPorts.length, 'The task has dynamic ports');
const addressesText = taskRow.find('td:eq(4)').text();
reservedPorts.forEach(port => {
assert.ok(addressesText.includes(port.Label), `Found label ${port.Label}`);
assert.ok(addressesText.includes(port.Value), `Found value ${port.Value}`);
});
});
test('/allocation/:id should list recent events for each task', function(assert) {
const tasks = server.db.taskStates.where({ allocationId: allocation.id });
assert.equal(
findAll('.task-state-events').length,
tasks.length,
'A task state event block per task'
);
});
test('each recent events list should include the name, state, and time info for the task', function(
assert
) {
const task = server.db.taskStates.where({ allocationId: allocation.id })[0];
const recentEventsSection = $(findAll('.task-state-events')[0]);
const heading = recentEventsSection
.find('.message-header')
.text()
.trim();
assert.ok(heading.includes(task.name), 'Task name');
assert.ok(heading.includes(task.state), 'Task state');
assert.ok(
heading.includes(moment(task.startedAt).format('MM/DD/YY HH:mm:ss [UTC]')),
'Task started at'
);
});
test('each recent events list should list all recent events for the task', function(assert) {
const task = server.db.taskStates.where({ allocationId: allocation.id })[0];
const events = server.db.taskEvents.where({ taskStateId: task.id });
assert.equal(
findAll('.task-state-events')[0].querySelectorAll('.task-events tbody tr').length,
events.length,
`Lists ${events.length} events`
);
});
test('each recent event should list the time, type, and description of the event', function(
assert
) {
const task = server.db.taskStates.where({ allocationId: allocation.id })[0];
const event = server.db.taskEvents.where({ taskStateId: task.id })[0];
const recentEvent = $('.task-state-events:eq(0) .task-events tbody tr:last');
assert.equal(
recentEvent
.find('td:eq(0)')
.text()
.trim(),
moment(event.time / 1000000).format('MM/DD/YY HH:mm:ss [UTC]'),
'Event timestamp'
);
assert.equal(
recentEvent
.find('td:eq(1)')
.text()
.trim(),
event.type,
'Event type'
);
assert.equal(
recentEvent
.find('td:eq(2)')
.text()
.trim(),
event.message,
'Event message'
);
dynamicPorts.forEach(port => {
assert.ok(addressesText.includes(port.Label), `Found label ${port.Label}`);
assert.ok(addressesText.includes(port.Value), `Found value ${port.Value}`);
});
});
test('when the allocation is not found, an error message is shown, but the URL persists', function(

View File

@ -0,0 +1,222 @@
import Ember from 'ember';
import { click, findAll, currentURL, find, visit } from 'ember-native-dom-helpers';
import { test } from 'qunit';
import moduleForAcceptance from 'nomad-ui/tests/helpers/module-for-acceptance';
import moment from 'moment';
import ipParts from 'nomad-ui/utils/ip-parts';
const { $ } = Ember;
let allocation;
let task;
moduleForAcceptance('Acceptance | task detail', {
beforeEach() {
server.create('agent');
server.create('node');
server.create('job', { createAllocations: false });
allocation = server.create('allocation', 'withTaskWithPorts', {
useMessagePassthru: true,
});
task = server.db.taskStates.where({ allocationId: allocation.id })[0];
visit(`/allocations/${allocation.id}/${task.name}`);
},
});
test('/allocation/:id/:task_name should name the task and list high-level task information', function(
assert
) {
assert.ok(find('.title').textContent.includes(task.name), 'Task name');
assert.ok(find('.title').textContent.includes(task.state), 'Task state');
const inlineDefinitions = findAll('.inline-definitions .pair');
assert.ok(
inlineDefinitions[0].textContent.includes(moment(task.startedAt).format('MM/DD/YY HH:mm:ss')),
'Task started at'
);
});
test('breadcrumbs includes allocations and link to the allocation detail page', function(assert) {
const breadcrumbs = findAll('.breadcrumb');
assert.equal(
breadcrumbs[0].textContent.trim(),
'Allocations',
'Allocations is the first breadcrumb'
);
assert.notEqual(
breadcrumbs[0].tagName.toLowerCase(),
'a',
'Allocations breadcrumb is not a link'
);
assert.equal(
breadcrumbs[1].textContent.trim(),
allocation.id.split('-')[0],
'Allocation short id is the second breadcrumb'
);
assert.equal(breadcrumbs[2].textContent.trim(), task.name, 'Task name is the third breadcrumb');
click(breadcrumbs[1]);
andThen(() => {
assert.equal(
currentURL(),
`/allocations/${allocation.id}`,
'Second breadcrumb links back to the allocation detail'
);
});
});
test('the addresses table lists all reserved and dynamic ports', function(assert) {
const taskResources = allocation.taskResourcesIds
.map(id => server.db.taskResources.find(id))
.sortBy('name')[0];
const reservedPorts = taskResources.resources.Networks[0].ReservedPorts;
const dynamicPorts = taskResources.resources.Networks[0].DynamicPorts;
const addresses = reservedPorts.concat(dynamicPorts);
assert.equal(
findAll('.addresses-list tbody tr').length,
addresses.length,
'All addresses are listed'
);
});
test('each address row shows the label and value of the address', function(assert) {
const node = server.db.nodes.find(allocation.nodeId);
const taskResources = allocation.taskResourcesIds
.map(id => server.db.taskResources.find(id))
.findBy('name', task.name);
const reservedPorts = taskResources.resources.Networks[0].ReservedPorts;
const dynamicPorts = taskResources.resources.Networks[0].DynamicPorts;
const address = reservedPorts.concat(dynamicPorts).sortBy('Label')[0];
const addressRow = $(find('.addresses-list tbody tr'));
assert.equal(
addressRow
.find('td:eq(0)')
.text()
.trim(),
reservedPorts.includes(address) ? 'No' : 'Yes',
'Dynamic port is denoted as such'
);
assert.equal(
addressRow
.find('td:eq(1)')
.text()
.trim(),
address.Label,
'Label'
);
assert.equal(
addressRow
.find('td:eq(2)')
.text()
.trim(),
`${ipParts(node.httpAddr).address}:${address.Value}`,
'Value'
);
});
test('the events table lists all recent events', function(assert) {
const events = server.db.taskEvents.where({ taskStateId: task.id });
assert.equal(
findAll('.task-events tbody tr').length,
events.length,
`Lists ${events.length} events`
);
});
test('each recent event should list the time, type, and description of the event', function(
assert
) {
const event = server.db.taskEvents.where({ taskStateId: task.id })[0];
const recentEvent = $('.task-events tbody tr:last');
assert.equal(
recentEvent
.find('td:eq(0)')
.text()
.trim(),
moment(event.time / 1000000).format('MM/DD/YY HH:mm:ss'),
'Event timestamp'
);
assert.equal(
recentEvent
.find('td:eq(1)')
.text()
.trim(),
event.type,
'Event type'
);
assert.equal(
recentEvent
.find('td:eq(2)')
.text()
.trim(),
event.message,
'Event message'
);
});
test('when the allocation is not found, the application errors', function(assert) {
visit(`/allocations/not-a-real-allocation/${task.name}`);
andThen(() => {
assert.equal(
server.pretender.handledRequests.findBy('status', 404).url,
'/v1/allocation/not-a-real-allocation',
'A request to the non-existent allocation is made'
);
assert.equal(
currentURL(),
`/allocations/not-a-real-allocation/${task.name}`,
'The URL persists'
);
assert.ok(find('.error-message'), 'Error message is shown');
assert.equal(
find('.error-message .title').textContent,
'Not Found',
'Error message is for 404'
);
});
});
test('when the allocation is found but the task is not, the application errors', function(assert) {
visit(`/allocations/${allocation.id}/not-a-real-task-name`);
andThen(() => {
assert.equal(
server.pretender.handledRequests.findBy('status', 200).url,
`/v1/allocation/${allocation.id}`,
'A request to the allocation is made successfully'
);
assert.equal(
currentURL(),
`/allocations/${allocation.id}/not-a-real-task-name`,
'The URL persists'
);
assert.ok(find('.error-message'), 'Error message is shown');
assert.equal(
find('.error-message .title').textContent,
'Not Found',
'Error message is for 404'
);
});
});
moduleForAcceptance('Acceptance | task detail (no addresses)', {
beforeEach() {
server.create('agent');
server.create('node');
server.create('job');
allocation = server.create('allocation', 'withoutTaskWithPorts');
task = server.db.taskStates.where({ allocationId: allocation.id })[0];
visit(`/allocations/${allocation.id}/${task.name}`);
},
});
test('when the task has no addresses, the addresses table is not shown', function(assert) {
assert.notOk(find('.addresses-list'), 'No addresses table');
});