Merge pull request #8742 from hashicorp/f-ui/poststart-poststop

UI: Add poststart and poststop lifecycle phases
This commit is contained in:
Michael Lange
2020-09-01 08:58:54 -07:00
committed by GitHub
13 changed files with 221 additions and 244 deletions

View File

@@ -23,4 +23,17 @@ export default class LifecycleChartRow extends Component {
return undefined;
}
@computed('task.lifecycleName')
get lifecycleLabel() {
const name = this.task.lifecycleName;
if (name.includes('sidecar')) {
return 'sidecar';
} else if (name.includes('ephemeral')) {
return name.substr(0, name.indexOf('-'));
} else {
return name;
}
}
}

View File

@@ -14,8 +14,11 @@ export default class LifecycleChart extends Component {
get lifecyclePhases() {
const tasksOrStates = this.taskStates || this.tasks;
const lifecycles = {
prestarts: [],
sidecars: [],
'prestart-ephemerals': [],
'prestart-sidecars': [],
'poststart-ephemerals': [],
'poststart-sidecars': [],
poststops: [],
mains: [],
};
@@ -25,18 +28,29 @@ export default class LifecycleChart extends Component {
});
const phases = [];
const stateActiveIterator = state => state.state === 'running';
if (lifecycles.prestarts.length || lifecycles.sidecars.length) {
if (lifecycles.mains.length < tasksOrStates.length) {
phases.push({
name: 'Prestart',
isActive: lifecycles.prestarts.some(state => state.state === 'running'),
isActive: lifecycles['prestart-ephemerals'].some(stateActiveIterator),
});
}
if (lifecycles.sidecars.length || lifecycles.mains.length) {
phases.push({
name: 'Main',
isActive: lifecycles.mains.some(state => state.state === 'running'),
isActive:
lifecycles.mains.some(stateActiveIterator) ||
lifecycles['poststart-ephemerals'].some(stateActiveIterator),
});
// Poststart is rendered as a subphase of main and therefore has no independent active state
phases.push({
name: 'Poststart',
});
phases.push({
name: 'Poststop',
isActive: lifecycles.poststops.some(stateActiveIterator),
});
}
@@ -55,12 +69,14 @@ export default class LifecycleChart extends Component {
}
const lifecycleNameSortPrefix = {
prestart: 0,
sidecar: 1,
'prestart-ephemeral': 0,
'prestart-sidecar': 1,
main: 2,
'poststart-sidecar': 3,
'poststart-ephemeral': 4,
poststop: 5,
};
function getTaskSortPrefix(task) {
// Prestarts first, then sidecars, then mains
return `${lifecycleNameSortPrefix[task.lifecycleName]}-${task.name}`;
}

View File

@@ -1,22 +1,10 @@
import Controller from '@ember/controller';
import { computed } from '@ember/object';
import { computed as overridable } from 'ember-overridable-computed';
import { task } from 'ember-concurrency';
import classic from 'ember-classic-decorator';
@classic
export default class IndexController extends Controller {
@computed('model.task.taskGroup.tasks.@each.name')
get otherTaskStates() {
const taskName = this.model.task.name;
return this.model.allocation.states.rejectBy('name', taskName);
}
@computed('otherTaskStates.@each.lifecycle')
get prestartTaskStates() {
return this.otherTaskStates.filterBy('task.lifecycle');
}
@overridable(() => {
// { title, description }
return null;

View File

@@ -14,8 +14,18 @@ export default class Task extends Fragment {
@computed('lifecycle', 'lifecycle.sidecar')
get lifecycleName() {
if (this.lifecycle && this.lifecycle.sidecar) return 'sidecar';
if (this.lifecycle && this.lifecycle.hook === 'prestart') return 'prestart';
if (this.lifecycle) {
const { hook, sidecar } = this.lifecycle;
if (hook === 'prestart') {
return sidecar ? 'prestart-sidecar' : 'prestart-ephemeral';
} else if (hook === 'poststart') {
return sidecar ? 'poststart-sidecar' : 'poststart-ephemeral';
} else if (hook === 'poststop') {
return 'poststop';
}
}
return 'main';
}

View File

@@ -11,7 +11,6 @@
.divider {
position: absolute;
left: 25%;
height: 100%;
stroke: $ui-gray-200;
@@ -19,6 +18,14 @@
stroke-dasharray: 1, 7;
stroke-dashoffset: 1;
stroke-linecap: square;
&.prestart {
left: 25%;
}
&.poststop {
left: 75%;
}
}
}
@@ -52,6 +59,16 @@
&.main {
left: 25%;
right: 25%;
}
&.poststart {
left: 35%;
right: 25%;
}
&.poststop {
left: 75%;
right: 0;
}
}
@@ -110,12 +127,34 @@
&.main {
margin-left: 25%;
margin-right: 25%;
}
&.prestart {
&.prestart-ephemeral {
margin-right: 75%;
}
&.prestart-sidecar {
margin-right: 25%;
}
&.poststart-ephemeral,
&.poststart-sidecar {
margin-left: 35%;
}
&.poststart-sidecar {
margin-right: 25%;
}
&.poststart-ephemeral {
margin-right: 35%;
}
&.poststop {
margin-left: 75%;
}
&:last-child .task {
margin-bottom: 0.9em;
}

View File

@@ -93,38 +93,6 @@
</div>
</div>
{{#if (and (not this.model.task.lifecycle) this.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">
<ListTable @source={{this.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>
</ListTable>
</div>
</div>
{{/if}}
{{#if this.model.task.volumeMounts.length}}
<div data-test-volumes class="boxed-section">
<div class="boxed-section-head">

View File

@@ -11,6 +11,6 @@
{{this.task.name}}
{{/if}}
</div>
<div class="lifecycle" data-test-lifecycle>{{capitalize this.task.lifecycleName}} Task</div>
<div class="lifecycle" data-test-lifecycle>{{capitalize this.lifecycleLabel}} Task</div>
</div>
</div>

View File

@@ -7,11 +7,14 @@
<div class="lifecycle-phases">
{{#each this.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="lifecycle-phase {{if phase.isActive "is-active"}} {{lowercase phase.name}}" data-test-lifecycle-phase>
<div class="name" data-test-name>{{phase.name}}</div>
</div>
{{/each}}
<svg class="divider">
<svg class="divider prestart">
<line x1="0" y1="0" x2="0" y2="100%" />
</svg>
<svg class="divider poststop">
<line x1="0" y1="0" x2="0" y2="100%" />
</svg>
</div>

View File

@@ -19,14 +19,20 @@ export default Factory.extend({
Resources: generateResources,
Lifecycle: i => {
const cycle = i % 3;
const cycle = i % 6;
if (cycle === 0) {
return null;
} else if (cycle === 1) {
return { Hook: 'prestart', Sidecar: false };
} else {
} else if (cycle === 2) {
return { Hook: 'prestart', Sidecar: true };
} else if (cycle === 3) {
return { Hook: 'poststart', Sidecar: false };
} else if (cycle === 4) {
return { Hook: 'poststart', Sidecar: true };
} else if (cycle === 5) {
return { Hook: 'poststop' };
}
},
});

View File

@@ -82,7 +82,7 @@ module('Acceptance | allocation detail', function(hooks) {
test('/allocation/:id should present task lifecycles', async function(assert) {
const job = server.create('job', {
groupsCount: 1,
groupTaskCount: 3,
groupTaskCount: 6,
withGroupServices: true,
createAllocations: false,
});
@@ -92,96 +92,23 @@ module('Acceptance | allocation detail', function(hooks) {
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);
}
});
assert.equal(Allocation.lifecycleChart.phases.length, 4);
assert.equal(Allocation.lifecycleChart.tasks.length, 6);
await Allocation.lifecycleChart.tasks[0].visit();
assert.equal(currentURL(), `/allocations/${allocation.id}/${sortedServerStates[0].name}`);
const prestartEphemeralTask = server.db.taskStates
.where({ allocationId: allocation.id })
.find(taskState => {
const task = server.db.tasks.findBy({ name: taskState.name });
return task.Lifecycle && task.Lifecycle.Hook === 'prestart' && !task.Lifecycle.Sidecar;
});
assert.equal(currentURL(), `/allocations/${allocation.id}/${prestartEphemeralTask.name}`);
});
test('/allocation/:id should list all tasks for the allocation', async function(assert) {

View File

@@ -101,86 +101,6 @@ 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.taskGroups.models.forEach(taskGroup => {
server.create('allocation', {
jobId: job.id,
taskGroup: taskGroup.name,
forceRunningClientStatus: true,
});
});
const taskGroup = job.taskGroups.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.taskGroups.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 events table lists all recent events', async function(assert) {
const events = server.db.taskEvents.where({ taskStateId: task.id });

View File

@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, settled } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { set } from '@ember/object';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { create } from 'ember-cli-page-object';
import LifecycleChart from 'nomad-ui/tests/pages/components/lifecycle-chart';
@@ -11,19 +12,31 @@ const Chart = create(LifecycleChart);
const tasks = [
{
lifecycleName: 'main',
name: 'main two',
name: 'main two: 3',
},
{
lifecycleName: 'main',
name: 'main one',
name: 'main one: 2',
},
{
lifecycleName: 'prestart',
name: 'prestart',
lifecycleName: 'prestart-ephemeral',
name: 'prestart ephemeral: 0',
},
{
lifecycleName: 'sidecar',
name: 'sidecar',
lifecycleName: 'prestart-sidecar',
name: 'prestart sidecar: 1',
},
{
lifecycleName: 'poststart-ephemeral',
name: 'poststart ephemeral: 5',
},
{
lifecycleName: 'poststart-sidecar',
name: 'poststart sidecar: 4',
},
{
lifecycleName: 'poststop',
name: 'poststop: 6',
},
];
@@ -38,20 +51,36 @@ module('Integration | Component | lifecycle-chart', function(hooks) {
assert.equal(Chart.phases[0].name, 'Prestart');
assert.equal(Chart.phases[1].name, 'Main');
assert.equal(Chart.phases[2].name, 'Poststart');
assert.equal(Chart.phases[3].name, 'Poststop');
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('name'), [
'prestart ephemeral: 0',
'prestart sidecar: 1',
'main one: 2',
'main two: 3',
'poststart sidecar: 4',
'poststart ephemeral: 5',
'poststop: 6',
]);
assert.deepEqual(Chart.tasks.mapBy('lifecycle'), [
'Prestart Task',
'Sidecar Task',
'Main Task',
'Main Task',
'Sidecar Task',
'Poststart Task',
'Poststop Task',
]);
assert.ok(Chart.tasks[0].isPrestart);
assert.ok(Chart.tasks[1].isSidecar);
assert.ok(Chart.tasks[0].isPrestartEphemeral);
assert.ok(Chart.tasks[1].isPrestartSidecar);
assert.ok(Chart.tasks[2].isMain);
assert.ok(Chart.tasks[4].isPoststartSidecar);
assert.ok(Chart.tasks[5].isPoststartEphemeral);
assert.ok(Chart.tasks[6].isPoststop);
Chart.tasks.forEach(task => {
assert.notOk(task.isActive);
@@ -72,6 +101,13 @@ module('Integration | Component | lifecycle-chart', function(hooks) {
assert.notOk(Chart.isPresent);
});
test('it renders all phases when there are any non-main tasks', async function(assert) {
this.set('tasks', [tasks[0], tasks[6]]);
await render(hbs`<LifecycleChart @tasks={{tasks}} />`);
assert.ok(Chart.phases.length, 4);
});
test('it reflects phase and task states when states are passed in', async function(assert) {
this.set(
'taskStates',
@@ -90,16 +126,64 @@ module('Integration | Component | lifecycle-chart', function(hooks) {
assert.notOk(task.isFinished);
});
this.set('taskStates.firstObject.state', 'running');
// Change poststart-ephemeral to be running
this.set('taskStates.4.state', 'running');
await settled();
assert.ok(Chart.phases[1].isActive);
assert.ok(Chart.tasks[3].isActive);
await componentA11yAudit(this.element, assert);
this.set('taskStates.firstObject.finishedAt', new Date());
assert.ok(Chart.tasks[5].isActive);
assert.ok(Chart.phases[1].isActive);
assert.notOk(
Chart.phases[2].isActive,
'the poststart phase is nested within main and should never have the active class'
);
this.set('taskStates.4.finishedAt', new Date());
await settled();
assert.ok(Chart.tasks[3].isFinished);
assert.ok(Chart.tasks[5].isFinished);
});
[
{
testName: 'expected active phases',
runningTaskNames: ['prestart ephemeral', 'main one', 'poststop'],
activePhaseNames: ['Prestart', 'Main', 'Poststop'],
},
{
testName: 'sidecar task states dont affect phase active states',
runningTaskNames: ['prestart sidecar', 'poststart sidecar'],
activePhaseNames: [],
},
{
testName: 'poststart ephemeral task states affect main phase active state',
runningTaskNames: ['poststart ephemeral'],
activePhaseNames: ['Main'],
},
].forEach(async ({ testName, runningTaskNames, activePhaseNames }) => {
test(testName, async function(assert) {
this.set('taskStates', tasks.map(task => ({ task })));
await render(hbs`<LifecycleChart @taskStates={{taskStates}} />`);
runningTaskNames.forEach(taskName => {
const taskState = this.get('taskStates').find(taskState =>
taskState.task.name.includes(taskName)
);
set(taskState, 'state', 'running');
});
await settled();
Chart.phases.forEach(Phase => {
if (activePhaseNames.includes(Phase.name)) {
assert.ok(Phase.isActive, `expected ${Phase.name} not to be active`);
} else {
assert.notOk(Phase.isActive, `expected ${Phase.name} phase not to be active`);
}
});
});
});
});

View File

@@ -19,8 +19,11 @@ export default {
isFinished: hasClass('is-finished'),
isMain: hasClass('main'),
isPrestart: hasClass('prestart'),
isSidecar: hasClass('sidecar'),
isPrestartEphemeral: hasClass('prestart-ephemeral'),
isPrestartSidecar: hasClass('prestart-sidecar'),
isPoststartEphemeral: hasClass('poststart-ephemeral'),
isPoststartSidecar: hasClass('poststart-sidecar'),
isPoststop: hasClass('poststop'),
visit: clickable('a'),
}),