diff --git a/.changelog/24985.txt b/.changelog/24985.txt new file mode 100644 index 000000000..551cabab8 --- /dev/null +++ b/.changelog/24985.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Contextualizes the Start Job button on whether it is startable, revertable, or not +``` diff --git a/ui/app/components/job-page/parts/title.js b/ui/app/components/job-page/parts/title.js index 148ee3489..45549c82f 100644 --- a/ui/app/components/job-page/parts/title.js +++ b/ui/app/components/job-page/parts/title.js @@ -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; diff --git a/ui/app/models/job.js b/ui/app/models/job.js index afc4a12fb..a6aa6f7e1 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -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( diff --git a/ui/app/routes/jobs/job/index.js b/ui/app/routes/jobs/job/index.js index 7b21383de..6328ddb82 100644 --- a/ui/app/routes/jobs/job/index.js +++ b/ui/app/routes/jobs/job/index.js @@ -68,7 +68,6 @@ export default class IndexRoute extends Route.extend(WithWatchers) { @watchRelationship('allocations') watchAllocations; @watchRelationship('evaluations') watchEvaluations; @watchRelationship('latestDeployment') watchLatestDeployment; - @collect( 'watchSummary', 'watchAllocations', diff --git a/ui/app/templates/components/job-page/parts/title.hbs b/ui/app/templates/components/job-page/parts/title.hbs index cd8d83592..391af3af6 100644 --- a/ui/app/templates/components/job-page/parts/title.hbs +++ b/ui/app/templates/components/job-page/parts/title.hbs @@ -29,13 +29,12 @@ {{/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)}} - - {{/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)}} + {{/if}} + {{/if}} - + {{else}} + {{#if this.job.hasStableNonCurrentVersion}} + + {{else}} + + {{/if}} + {{/if}} {{/if}} diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index d613d31c4..87609668d 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -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 })); +} diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js index 6ce78868c..a525b8fc3 100644 --- a/ui/tests/acceptance/job-detail-test.js +++ b/ui/tests/acceptance/job-detail-test.js @@ -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` + ); + }); +}); diff --git a/ui/tests/pages/jobs/detail.js b/ui/tests/pages/jobs/detail.js index 94a2c465d..69333b3b1 100644 --- a/ui/tests/pages/jobs/detail.js +++ b/ui/tests/pages/jobs/detail.js @@ -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]'),