[ui] Multi-condition start/revert/edit buttons when a job isn't running (#24985)

* Multi-condition start/revert/edit buttons when a job isn't running

* mirage-mocked revertable jobs and acceptance tests

* Remove version-watching from job index route
This commit is contained in:
Phil Renaud
2025-02-03 22:36:50 -05:00
committed by GitHub
parent ec073d0eab
commit 389f4612b6
8 changed files with 266 additions and 23 deletions

3
.changelog/24985.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: Contextualizes the Start Job button on whether it is startable, revertable, or not
```

View File

@@ -118,6 +118,14 @@ export default class Title extends Component {
})
startJob;
@task(function* (version) {
if (!version) {
return;
}
yield version.revertTo();
})
revertTo;
get description() {
if (!this.job.ui?.Description) {
return null;

View File

@@ -446,6 +446,21 @@ export default class Job extends Model {
@hasMany('recommendation-summary') recommendationSummaries;
@computed('versions.@each.stable')
get hasStableNonCurrentVersion() {
return this.versions
.sortBy('number')
.reverse()
.slice(1)
.any((version) => version.get('stable'));
}
@computed('versions.@each.stable')
get latestStableVersion() {
return this.versions.filterBy('stable').sortBy('number').reverse().slice(1)
.firstObject;
}
get actions() {
return this.taskGroups.reduce((acc, taskGroup) => {
return acc.concat(

View File

@@ -68,7 +68,6 @@ export default class IndexRoute extends Route.extend(WithWatchers) {
@watchRelationship('allocations') watchAllocations;
@watchRelationship('evaluations') watchEvaluations;
@watchRelationship('latestDeployment') watchLatestDeployment;
@collect(
'watchSummary',
'watchAllocations',

View File

@@ -29,13 +29,12 @@
</PH.Generic>
{{/if}}
<PH.Actions>
{{#if (not (eq this.job.status "dead"))}}
{{#if (can "exec allocation" namespace=this.job.namespace)}}
{{#if (and this.job.actions.length this.job.allocations.length)}}
<ActionsDropdown @actions={{this.job.actions}} />
{{/if}}
{{#if (not (eq this.job.status "dead"))}}
{{#if (can "exec allocation" namespace=this.job.namespace)}}
{{#if (and this.job.actions.length this.job.allocations.length)}}
<ActionsDropdown @actions={{this.job.actions}} />
{{/if}}
{{/if}}
<Exec::OpenButton @job={{this.job}} />
<TwoStepButton
data-test-stop
@@ -46,7 +45,7 @@
@confirmationMessage="Are you sure you want to stop this job?"
@awaitingConfirmation={{this.stopJob.isRunning}}
@onConfirm={{perform this.stopJob}}
{{keyboard-shortcut
{{keyboard-shortcut
label="Stop"
pattern=(array "s" "t" "o" "p")
action=(perform this.stopJob true)
@@ -61,27 +60,54 @@
@confirmationMessage="Are you sure? You cannot undo this action."
@awaitingConfirmation={{this.purgeJob.isRunning}}
@onConfirm={{perform this.purgeJob}}
{{keyboard-shortcut
{{keyboard-shortcut
label="Purge"
pattern=(array "p" "u" "r" "g" "e")
action=(perform this.purgeJob)
}}
/>
<TwoStepButton
data-test-start
@alignRight={{true}}
@idleText="Start Job"
@cancelText="Cancel"
@confirmText="Yes, Start Job"
@confirmationMessage="Are you sure you want to start this job?"
@awaitingConfirmation={{this.startJob.isRunning}}
@onConfirm={{perform this.startJob}}
{{keyboard-shortcut
label="Start"
pattern=(array "s" "t" "a" "r" "t")
action=(perform this.startJob true)
}}
{{!--
1. If job.stopped is true, that means the job was manually stopped and can be restared. So we should show the "start" button.
2. If job.stopped is false, but if job.status is "dead", that means the job has failed and can't be restarted necessarily. We should should check to see that there's a stable verison of the job to fall back to.
2a. If there is a stable version, we should show a "Revert to last stable version" button
2b. If there is no stable version, we should show an "Edit and resubmit" button
--}}
{{#if this.job.stopped}}
<TwoStepButton
data-test-start
@alignRight={{true}}
@idleText="Start Job"
@cancelText="Cancel"
@confirmText="Yes, Start Job"
@confirmationMessage="Are you sure you want to start this job?"
@awaitingConfirmation={{this.startJob.isRunning}}
@onConfirm={{perform this.startJob}}
{{keyboard-shortcut
label="Start"
pattern=(array "s" "t" "a" "r" "t")
action=(perform this.startJob true)
}}
/>
{{else}}
{{#if this.job.hasStableNonCurrentVersion}}
<TwoStepButton
data-test-revert
@alignRight={{true}}
@idleText="Revert to last stable version (v{{this.job.latestStableVersion.number}})"
@cancelText="Cancel"
@confirmText="Yes, Revert to last stable version"
@confirmationMessage="Are you sure you want to revert to the last stable version?"
@awaitingConfirmation={{this.revertTo.isRunning}}
@onConfirm={{perform this.revertTo this.job.latestStableVersion}}
/>
{{else}}
<Hds::Button
data-test-edit-and-resubmit
{{hds-tooltip "This job has failed and has no stable previous version to fall back to. You can edit and resubmit the job to try again." options=(hash placement="bottom")}}
@color="primary" @isInline={{true}} @text="Edit and Resubmit job" @route={{"jobs.job.definition" this.job.id}} @query={{hash isEditing=true}} />
{{/if}}
{{/if}}
{{/if}}
</PH.Actions>
</Hds::PageHeader>

View File

@@ -380,6 +380,8 @@ function smallCluster(server) {
// #endregion Version Tags
createRestartableJobs(server);
server.create('job', {
name: 'hcl-definition-job',
id: 'display-hcl',
@@ -1215,3 +1217,117 @@ function getScenarioQueryParameter() {
return mirageScenario;
}
/* eslint-enable */
export function createRestartableJobs(server) {
const restartableJob = server.create('job', {
name: 'restartable-job',
stopped: true,
status: 'dead',
noDeployments: true,
shallow: true,
createAllocations: false,
groupAllocCount: 0,
});
const revertableJob = server.create('job', {
name: 'revertable-job',
stopped: false,
status: 'dead',
noDeployments: true,
shallow: true,
createAllocations: false,
groupAllocCount: 0,
});
const nonRevertableJob = server.create('job', {
name: 'non-revertable-job',
stopped: false,
status: 'dead',
shallow: true,
createAllocations: false,
groupAllocCount: 0,
});
// So it shows up as "Failed" instead of "Scaled Down"
restartableJob.taskGroups.models[0].update({
count: 1,
});
revertableJob.taskGroups.models[0].update({
count: 1,
});
nonRevertableJob.taskGroups.models[0].update({
count: 1,
});
// Remove all job-versions inherently created
server.schema.jobVersions
.all()
.filter((v) => v.jobId === restartableJob.id)
.models.forEach((v) => v.destroy());
server.schema.jobVersions
.all()
.filter((v) => v.jobId === revertableJob.id)
.models.forEach((v) => v.destroy());
server.schema.jobVersions
.all()
.filter((v) => v.jobId === nonRevertableJob.id)
.models.forEach((v) => v.destroy());
server.create('job-version', {
job: revertableJob,
namespace: revertableJob.namespace,
version: 0,
stable: false,
versionTag: {
Name: 'v0',
Description: 'The first version',
},
});
server.create('job-version', {
job: revertableJob,
namespace: revertableJob.namespace,
version: 1,
stable: true,
versionTag: {
Name: 'v1',
Description: 'The second version',
},
});
server.create('job-version', {
job: revertableJob,
namespace: revertableJob.namespace,
version: 2,
stable: false,
versionTag: {
Name: 'v2',
Description: 'The third version',
},
});
server.create('job-version', {
job: nonRevertableJob,
namespace: nonRevertableJob.namespace,
version: 0,
stable: false,
noActiveDeployment: true,
});
server.create('job-version', {
job: nonRevertableJob,
namespace: nonRevertableJob.namespace,
version: 1,
stable: false,
noActiveDeployment: true,
});
server.schema.jobVersions
.all()
.filter((v) => v.jobId === revertableJob.id)
.models.forEach((v) => v.update({ stable: true }));
server.schema.jobVersions
.all()
.filter((v) => v.jobId === nonRevertableJob.id)
.models.forEach((v) => v.update({ stable: false }));
}

View File

@@ -16,6 +16,7 @@ import moduleForJob, {
} from 'nomad-ui/tests/helpers/module-for-job';
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
import percySnapshot from '@percy/ember';
import { createRestartableJobs } from 'nomad-ui/mirage/scenarios/default';
moduleForJob('Acceptance | job detail (batch)', 'allocations', () =>
server.create('job', {
@@ -787,3 +788,73 @@ module('Acceptance | job detail (with namespaces)', function (hooks) {
.exists('A toast error message pops up.');
});
});
module('Job Start/Stop/Revert/Edit and Resubmit', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
server.create('agent');
server.create('node-pool');
server.create('node');
createRestartableJobs(server);
});
test('Start Job depends on the job being stopped', async function (assert) {
const restartableJob = server.db.jobs.findBy(
(j) => j.name === 'restartable-job'
);
const revertableJob = server.db.jobs.findBy(
(j) => j.name === 'revertable-job'
);
const nonRevertableJob = server.db.jobs.findBy(
(j) => j.name === 'non-revertable-job'
);
await JobDetail.visit({ id: restartableJob.id });
assert.ok(JobDetail.start.isPresent);
assert.notOk(JobDetail.stop.isPresent);
assert.notOk(JobDetail.revert.isPresent);
assert.notOk(JobDetail.editAndResubmit.isPresent);
await percySnapshot('Start Job depends on the job being stopped');
await JobDetail.visit({ id: revertableJob.id });
assert.notOk(JobDetail.start.isPresent);
await percySnapshot('Revertable Job depends on having stable job versions');
await JobDetail.visit({ id: nonRevertableJob.id });
assert.notOk(JobDetail.start.isPresent);
await percySnapshot(
'Non-revertable Job depends on having no stable job versions'
);
});
test('A revertable job depends on having stable job versions', async function (assert) {
const revertableJob = server.db.jobs.findBy(
(j) => j.name === 'revertable-job'
);
const nonRevertableJob = server.db.jobs.findBy(
(j) => j.name === 'non-revertable-job'
);
await JobDetail.visit({ id: revertableJob.id });
assert.ok(JobDetail.revert.isPresent);
assert.equal(JobDetail.revert.text, 'Revert to last stable version (v1)');
await JobDetail.visit({ id: nonRevertableJob.id });
assert.notOk(JobDetail.revert.isPresent);
assert.ok(JobDetail.editAndResubmit.isPresent);
});
test('Clicking the resubmit button navigates to the job definition page in edit mode', async function (assert) {
const job = server.db.jobs.findBy((j) => j.name === 'non-revertable-job');
await JobDetail.visit({ id: job.id });
await JobDetail.editAndResubmit.click();
assert.equal(
currentURL(),
`/jobs/${job.id}/definition?isEditing=true&view=job-spec`
);
});
});

View File

@@ -43,6 +43,11 @@ export default create({
stop: twoStepButton('[data-test-stop]'),
start: twoStepButton('[data-test-start]'),
purge: twoStepButton('[data-test-purge]'),
revert: twoStepButton('[data-test-revert]'),
editAndResubmit: {
scope: '[data-test-edit-and-resubmit]',
click: clickable(),
},
packTag: isPresent('[data-test-pack-tag]'),
metaTable: isPresent('[data-test-meta]'),