From 6aa93c8917549a87166d6ec5003d9c9bbf96c79a Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Mon, 30 Oct 2017 21:02:40 -0700 Subject: [PATCH] Remold the allocation detail and task detail pages Now that there is a task detail page, some of the content from the allocation detail page is better suited there. --- .../allocations/allocation/task/index.js | 24 ++ ui/app/models/task-state.js | 11 + ui/app/styles/components/breadcrumbs.scss | 10 +- .../allocations/allocation/index.hbs | 136 +++++------ .../allocations/allocation/task/index.hbs | 92 +++++++- .../allocations/allocation/task/subnav.hbs | 2 +- ui/mirage/common.js | 15 +- ui/mirage/factories/allocation.js | 18 ++ ui/mirage/factories/task-resources.js | 4 + ui/tests/acceptance/allocation-detail-test.js | 77 +----- ui/tests/acceptance/task-detail-test.js | 222 ++++++++++++++++++ 11 files changed, 444 insertions(+), 167 deletions(-) create mode 100644 ui/app/controllers/allocations/allocation/task/index.js create mode 100644 ui/tests/acceptance/task-detail-test.js diff --git a/ui/app/controllers/allocations/allocation/task/index.js b/ui/app/controllers/allocations/allocation/task/index.js new file mode 100644 index 000000000000..7d42ea8b5afb --- /dev/null +++ b/ui/app/controllers/allocations/allocation/task/index.js @@ -0,0 +1,24 @@ +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() { + const ports = 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'); + return ports; + }), +}); diff --git a/ui/app/models/task-state.js b/ui/app/models/task-state.js index e67b0110b704..a5b3bff22ddb 100644 --- a/ui/app/models/task-state.js +++ b/ui/app/models/task-state.js @@ -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'; + }), }); diff --git a/ui/app/styles/components/breadcrumbs.scss b/ui/app/styles/components/breadcrumbs.scss index e7d5942e68ba..c3ec634a6b4e 100644 --- a/ui/app/styles/components/breadcrumbs.scss +++ b/ui/app/styles/components/breadcrumbs.scss @@ -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; + } } diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 8fc8c190d24e..3478903c3fdb 100644 --- a/ui/app/templates/allocations/allocation/index.hbs +++ b/ui/app/templates/allocations/allocation/index.hbs @@ -1,5 +1,8 @@ {{#global-header class="page-header"}} - Allocations + Allocations + {{#link-to "allocations.allocation" model class="breadcrumb"}} + {{model.shortId}} + {{/link-to}} {{/global-header}} {{#gutter-menu class="page-body"}}
@@ -21,92 +24,59 @@ -
-
+
+
Tasks
- {{#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}} - Last Event - {{#t.sort-by prop="events.lastObject.time"}}Time{{/t.sort-by}} - Addresses - {{/t.head}} - {{#t.body as |row|}} - - - {{#link-to "allocations.allocation.task" row.model.allocation row.model}} - {{row.model.task.name}} - {{/link-to}} - - {{row.model.state}} - - {{#if row.model.events.lastObject.displayMessage}} - {{row.model.events.lastObject.displayMessage}} - {{else}} - No message - {{/if}} - - {{moment-format row.model.events.lastObject.time "MM/DD/YY HH:mm:ss [UTC]"}} - - - - - {{/t.body}} - {{/list-table}} -
- - {{#each model.states as |state|}} -
-
- {{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}} -
- - +
+ {{#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}} +
+ {{#t.sort-by prop="events.lastObject.time"}}Time{{/t.sort-by}} + + {{/t.head}} + {{#t.body as |row|}} - - - + + + + + - - - {{#each (reverse state.events) as |event|}} - - - - - - {{/each}} - -
Last EventAddresses
TimeTypeDescription + {{#link-to "allocations.allocation.task" row.model.allocation row.model}} + {{row.model.task.name}} + {{/link-to}} + {{row.model.state}} + {{#if row.model.events.lastObject.displayMessage}} + {{row.model.events.lastObject.displayMessage}} + {{else}} + No message + {{/if}} + {{moment-format row.model.events.lastObject.time "MM/DD/YY HH:mm:ss [UTC]"}} + +
{{moment-format event.time "MM/DD/YY HH:mm:ss [UTC]"}}{{event.type}} - {{#if event.displayMessage}} - {{event.displayMessage}} - {{else}} - No message - {{/if}} -
+ {{/t.body}} + {{/list-table}}
- {{/each}} +
{{/gutter-menu}} diff --git a/ui/app/templates/allocations/allocation/task/index.hbs b/ui/app/templates/allocations/allocation/task/index.hbs index 22e7dbef9cd4..20dcf01ac4c5 100644 --- a/ui/app/templates/allocations/allocation/task/index.hbs +++ b/ui/app/templates/allocations/allocation/task/index.hbs @@ -1,12 +1,94 @@ {{#global-header class="page-header"}} + Allocations + {{#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"}}
- +

+ {{model.name}} + {{model.state}} +

+ +
+
+ Task Details + + Started At + {{moment-format model.startedAt "MM/DD/YY HH:mm:ss"}} + + {{#if model.finishedAt}} + + Finished At + {{moment-format model.finishedAt "MM/DD/YY HH:mm:ss"}} + + {{/if}} + + Driver + {{model.task.driver}} + +
+
+ + {{#if ports.length}} +
+
+ Addresses +
+
+ {{#list-table source=ports class="addresses-list" as |t|}} + {{#t.head}} + Dynamic? + Name + Address + {{/t.head}} + {{#t.body as |row|}} + + {{if row.model.isDynamic "Yes" "No"}} + {{row.model.name}} + + + {{model.allocation.node.address}}:{{row.model.port}} + + + + {{/t.body}} + {{/list-table}} +
+
+ {{/if}} + +
+
+ Recent Events +
+
+ {{#list-table source=(reverse model.events) class="is-striped task-events" as |t|}} + {{#t.head}} + Time + Type + Description + {{/t.head}} + {{#t.body as |row|}} + + {{moment-format row.model.time "MM/DD/YY HH:mm:ss"}} + {{row.model.type}} + + {{#if row.model.displayMessage}} + {{row.model.displayMessage}} + {{else}} + No message + {{/if}} + + + {{/t.body}} + {{/list-table}} +
+
{{/gutter-menu}} diff --git a/ui/app/templates/allocations/allocation/task/subnav.hbs b/ui/app/templates/allocations/allocation/task/subnav.hbs index f59c0cb66b01..fec8ff349fe9 100644 --- a/ui/app/templates/allocations/allocation/task/subnav.hbs +++ b/ui/app/templates/allocations/allocation/task/subnav.hbs @@ -1,5 +1,5 @@
diff --git a/ui/mirage/common.js b/ui/mirage/common.js index 5bc38ac244b9..c9c5de517526 100644 --- a/ui/mirage/common.js +++ b/ui/mirage/common.js @@ -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) diff --git a/ui/mirage/factories/allocation.js b/ui/mirage/factories/allocation.js index e4bcad24c88b..1a19e4637ab2 100644 --- a/ui/mirage/factories/allocation.js +++ b/ui/mirage/factories/allocation.js @@ -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', diff --git a/ui/mirage/factories/task-resources.js b/ui/mirage/factories/task-resources.js index e6fe87de85e7..782988bcda61 100644 --- a/ui/mirage/factories/task-resources.js +++ b/ui/mirage/factories/task-resources.js @@ -9,4 +9,8 @@ export default Factory.extend({ withReservedPorts: trait({ resources: () => generateResources({ networks: { minPorts: 1 } }), }), + + withoutReservedPorts: trait({ + resources: () => generateResources({ networks: { minPorts: 0, maxPorts: 0 } }), + }), }); diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js index 29803fd4f1ec..916eccb5d7e8 100644 --- a/ui/tests/acceptance/allocation-detail-test.js +++ b/ui/tests/acceptance/allocation-detail-test.js @@ -72,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]; @@ -110,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 reserved 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( diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js new file mode 100644 index 000000000000..1d031cbec551 --- /dev/null +++ b/ui/tests/acceptance/task-detail-test.js @@ -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'); +});