diff --git a/.changelog/16932.txt b/.changelog/16932.txt new file mode 100644 index 000000000..267c86bd2 --- /dev/null +++ b/.changelog/16932.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Job status and deployment redesign +``` diff --git a/ui/app/components/conditional-link-to.hbs b/ui/app/components/conditional-link-to.hbs new file mode 100644 index 000000000..00766532d --- /dev/null +++ b/ui/app/components/conditional-link-to.hbs @@ -0,0 +1,9 @@ +{{#if @condition}} + + {{yield}} + +{{else}} + + {{yield}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/conditional-link-to.js b/ui/app/components/conditional-link-to.js new file mode 100644 index 000000000..7dd1d3b31 --- /dev/null +++ b/ui/app/components/conditional-link-to.js @@ -0,0 +1,7 @@ +import Component from '@glimmer/component'; + +export default class ConditionalLinkToComponent extends Component { + get query() { + return this.args.query || {}; + } +} diff --git a/ui/app/components/job-page/parts/summary-chart.js b/ui/app/components/job-page/parts/summary-chart.js new file mode 100644 index 000000000..754e1de5f --- /dev/null +++ b/ui/app/components/job-page/parts/summary-chart.js @@ -0,0 +1,24 @@ +// @ts-check +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { camelize } from '@ember/string'; +import { inject as service } from '@ember/service'; + +export default class JobPagePartsSummaryChartComponent extends Component { + @service router; + + @action + gotoAllocations(status) { + this.router.transitionTo('jobs.job.allocations', this.args.job, { + queryParams: { + status: JSON.stringify(status), + namespace: this.args.job.get('namespace.name'), + }, + }); + } + + @action + onSliceClick(ev, slice) { + this.gotoAllocations([camelize(slice.label)]); + } +} diff --git a/ui/app/components/job-page/parts/summary.js b/ui/app/components/job-page/parts/summary.js index 6bc8e3414..47a8949c8 100644 --- a/ui/app/components/job-page/parts/summary.js +++ b/ui/app/components/job-page/parts/summary.js @@ -4,11 +4,10 @@ */ import Component from '@ember/component'; -import { action, computed } from '@ember/object'; +import { computed } from '@ember/object'; import { inject as service } from '@ember/service'; import { classNames } from '@ember-decorators/component'; import classic from 'ember-classic-decorator'; -import { camelize } from '@ember/string'; @classic @classNames('boxed-section') export default class Summary extends Component { @@ -17,21 +16,6 @@ export default class Summary extends Component { job = null; forceCollapsed = false; - @action - gotoAllocations(status) { - this.router.transitionTo('jobs.job.allocations', this.job, { - queryParams: { - status: JSON.stringify(status), - namespace: this.job.get('namespace.name'), - }, - }); - } - - @action - onSliceClick(ev, slice) { - this.gotoAllocations([camelize(slice.label)]); - } - @computed('forceCollapsed') get isExpanded() { if (this.forceCollapsed) return false; diff --git a/ui/app/components/job-status/allocation-status-block.hbs b/ui/app/components/job-status/allocation-status-block.hbs new file mode 100644 index 000000000..5ff785fc3 --- /dev/null +++ b/ui/app/components/job-status/allocation-status-block.hbs @@ -0,0 +1,58 @@ +
+ {{#if this.countToShow}} +
+ {{#each (range 0 this.countToShow) as |i|}} + + {{#if (and (eq @status "running") (not @steady))}} + {{#if (eq @canary "canary")}} + + {{/if}} + + {{#if (eq @health "healthy")}} + + {{else}} + + {{/if}} + + {{/if}} + + {{/each}} +
+ {{/if}} + {{#if this.remaining}} + + + {{#if this.countToShow}}+{{/if}}{{this.remaining}} + {{#unless @steady}} + {{#if (eq @canary "canary")}} + + {{/if}} + {{#if (eq @status "running")}} + + {{#if (eq @health "healthy")}} + + {{else}} + + {{/if}} + + {{/if}} + {{/unless}} + + {{/if}} +
\ No newline at end of file diff --git a/ui/app/components/job-status/allocation-status-block.js b/ui/app/components/job-status/allocation-status-block.js new file mode 100644 index 000000000..5f4c291d8 --- /dev/null +++ b/ui/app/components/job-status/allocation-status-block.js @@ -0,0 +1,15 @@ +import Component from '@glimmer/component'; + +export default class JobStatusAllocationStatusBlockComponent extends Component { + get countToShow() { + const restWidth = 50; + const restGap = 10; + let cts = Math.floor((this.args.width - (restWidth + restGap)) / 42); + // Either show 3+ or show only a single/remaining box + return cts > 3 ? cts : 0; + } + + get remaining() { + return this.args.count - this.countToShow; + } +} diff --git a/ui/app/components/job-status/allocation-status-row.hbs b/ui/app/components/job-status/allocation-status-row.hbs new file mode 100644 index 000000000..2b04ecb56 --- /dev/null +++ b/ui/app/components/job-status/allocation-status-row.hbs @@ -0,0 +1,64 @@ +
+ {{#if this.showSummaries}} +
+ {{#each-in @allocBlocks as |status allocsByStatus|}} + {{#each-in allocsByStatus as |health allocsByHealth|}} + {{#each-in allocsByHealth as |canary allocsByCanary|}} + {{#if (gt allocsByCanary.length 0)}} + + {{/if}} + {{/each-in}} + {{/each-in}} + {{/each-in}} +
+ {{else}} +
+ {{#each-in @allocBlocks as |status allocsByStatus|}} + {{#each-in allocsByStatus as |health allocsByHealth|}} + {{#each-in allocsByHealth as |canary allocsByCanary|}} + {{#if (gt allocsByCanary.length 0)}} + {{#each (range 0 allocsByCanary.length) as |i|}} + + {{#unless @steady}} + {{#if (eq canary "canary")}} + + {{/if}} + {{#if (eq status "running")}} + + {{#if (eq health "healthy")}} + + {{else}} + + {{/if}} + + {{/if}} + {{/unless}} + + {{/each}} + {{/if}} + {{/each-in}} + {{/each-in}} + {{/each-in}} +
+ {{/if}} +
+ diff --git a/ui/app/components/job-status/allocation-status-row.js b/ui/app/components/job-status/allocation-status-row.js new file mode 100644 index 000000000..a23429347 --- /dev/null +++ b/ui/app/components/job-status/allocation-status-row.js @@ -0,0 +1,43 @@ +// @ts-check +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +const ALLOC_BLOCK_WIDTH = 32; +const ALLOC_BLOCK_GAP = 10; + +export default class JobStatusAllocationStatusRowComponent extends Component { + @tracked width = 0; + + get allocBlockSlots() { + return Object.values(this.args.allocBlocks) + .flatMap((statusObj) => Object.values(statusObj)) + .flatMap((healthObj) => Object.values(healthObj)) + .reduce( + (totalSlots, allocsByCanary) => + totalSlots + (allocsByCanary ? allocsByCanary.length : 0), + 0 + ); + } + + get showSummaries() { + return ( + this.allocBlockSlots * (ALLOC_BLOCK_WIDTH + ALLOC_BLOCK_GAP) - + ALLOC_BLOCK_GAP > + this.width + ); + } + + calcPerc(count) { + return (count / this.allocBlockSlots) * this.width; + } + + @action reflow(element) { + this.width = element.clientWidth; + } + + @action + captureElement(element) { + this.width = element.clientWidth; + } +} diff --git a/ui/app/components/job-status/deployment-history.hbs b/ui/app/components/job-status/deployment-history.hbs new file mode 100644 index 000000000..ec0efacc3 --- /dev/null +++ b/ui/app/components/job-status/deployment-history.hbs @@ -0,0 +1,53 @@ +
+
+

Deployment History

+ +
+
    + {{#each this.history as |deployment-log|}} +
  1. +
    + {{deployment-log.state.allocation.shortId}} + {{deployment-log.type}}: {{deployment-log.message}} + + {{format-ts deployment-log.time}} + +
    +
  2. + {{else}} + {{#if this.errorState}} +
  3. +
    + Error loading deployment history +
    +
  4. + {{else}} + {{#if this.deploymentAllocations.length}} + {{#if this.searchTerm}} +
  5. +
    + No events match {{this.searchTerm}} +
    +
  6. + {{else}} +
  7. +
    + No deployment events yet +
    +
  8. + {{/if}} + {{else}} +
  9. +
    + Loading deployment events +
    +
  10. + {{/if}} + {{/if}} + {{/each}} +
+
diff --git a/ui/app/components/job-status/deployment-history.js b/ui/app/components/job-status/deployment-history.js new file mode 100644 index 000000000..61d76b2ba --- /dev/null +++ b/ui/app/components/job-status/deployment-history.js @@ -0,0 +1,96 @@ +// @ts-check +import Component from '@glimmer/component'; +import { alias } from '@ember/object/computed'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class JobStatusDeploymentHistoryComponent extends Component { + @service notifications; + + /** + * @type { Error } + */ + @tracked errorState = null; + + /** + * @type { import('../../models/job').default } + */ + @alias('args.deployment.job') job; + + /** + * @type { number } + */ + @alias('args.deployment.versionNumber') deploymentVersion; + + /** + * Get all allocations for the job + * @type { import('../../models/allocation').default[] } + */ + get jobAllocations() { + return this.job.get('allocations'); + } + + /** + * Filter the job's allocations to only those that are part of the deployment + * @type { import('../../models/allocation').default[] } + */ + get deploymentAllocations() { + return this.jobAllocations.filter( + (alloc) => alloc.jobVersion === this.deploymentVersion + ); + } + + /** + * Map the deployment's allocations to their task events, in reverse-chronological order + * @type { import('../../models/task-event').default[] } + */ + get history() { + try { + return this.deploymentAllocations + .map((a) => + a + .get('states') + .map((s) => s.events.content) + .flat() + ) + .flat() + .filter((a) => this.containsSearchTerm(a)) + .sort((a, b) => a.get('time') - b.get('time')) + .reverse(); + } catch (e) { + this.triggerError(e); + return []; + } + } + + @action triggerError(error) { + this.errorState = error; + this.notifications.add({ + title: 'Could not fetch deployment history', + message: error, + color: 'critical', + }); + } + + // #region search + + /** + * @type { string } + */ + @tracked searchTerm = ''; + + /** + * @param { import('../../models/task-event').default } taskEvent + * @returns { boolean } + */ + containsSearchTerm(taskEvent) { + return ( + taskEvent.message.toLowerCase().includes(this.searchTerm.toLowerCase()) || + taskEvent.type.toLowerCase().includes(this.searchTerm.toLowerCase()) || + taskEvent.state.allocation.shortId.includes(this.searchTerm.toLowerCase()) + ); + } + + // #endregion search +} diff --git a/ui/app/components/job-status/panel.hbs b/ui/app/components/job-status/panel.hbs new file mode 100644 index 000000000..1bd4fc08c --- /dev/null +++ b/ui/app/components/job-status/panel.hbs @@ -0,0 +1,5 @@ +{{#if this.isActivelyDeploying}} + +{{else}} + +{{/if}} diff --git a/ui/app/components/job-status/panel.js b/ui/app/components/job-status/panel.js new file mode 100644 index 000000000..e9f05dd66 --- /dev/null +++ b/ui/app/components/job-status/panel.js @@ -0,0 +1,8 @@ +// @ts-check +import Component from '@glimmer/component'; + +export default class JobStatusPanelComponent extends Component { + get isActivelyDeploying() { + return this.args.job.get('latestDeployment.isRunning'); + } +} diff --git a/ui/app/components/job-status/panel/deploying.hbs b/ui/app/components/job-status/panel/deploying.hbs new file mode 100644 index 000000000..27213a0a2 --- /dev/null +++ b/ui/app/components/job-status/panel/deploying.hbs @@ -0,0 +1,113 @@ +
+
+
+ Deployment Status + {{@job.latestDeployment.shortId}} +
+ {{#if @job.latestDeployment.isRunning}} + + {{/if}} + {{#if @job.latestDeployment.requiresPromotion}} + + {{/if}} +
+
+
+
+
+ {{#if this.oldVersionAllocBlockIDs.length}} +

Previous allocations: {{#if this.oldVersionAllocBlocks.running}}{{this.oldRunningHealthyAllocBlocks.length}} running{{/if}}

+
+ +
+
+ + + + {{get this.oldRunningHealthyAllocBlocks "length"}} Running + + + + {{get this.oldCompleteHealthyAllocBlocks "length"}} Complete + + +
+ + {{/if}} + +

New allocations: {{this.newRunningHealthyAllocBlocks.length}}/{{this.totalAllocs}} running and healthy

+
+ +
+
+ +
+ + {{!-- Legend by Status, then by Health, then by Canary --}} + + {{#each-in this.newAllocsByStatus as |status count|}} + + + {{count}} {{capitalize status}} + + {{/each-in}} + + {{#each-in this.newAllocsByHealth as |health count|}} + + + + {{#if (eq health "healthy")}} + + {{else}} + + {{/if}} + + + {{count}} {{capitalize health}} + + {{/each-in}} + + + + + + {{this.newAllocsByCanary.canary}} Canary + + + +
+ +
+ + +
+ +
+
diff --git a/ui/app/components/job-status/panel/deploying.js b/ui/app/components/job-status/panel/deploying.js new file mode 100644 index 000000000..966dcac7e --- /dev/null +++ b/ui/app/components/job-status/panel/deploying.js @@ -0,0 +1,185 @@ +// @ts-check +import Component from '@glimmer/component'; +import { task } from 'ember-concurrency'; +import { tracked } from '@glimmer/tracking'; +import { alias } from '@ember/object/computed'; +import messageFromAdapterError from 'nomad-ui/utils/message-from-adapter-error'; + +export default class JobStatusPanelDeployingComponent extends Component { + @alias('args.job') job; + @alias('args.handleError') handleError = () => {}; + + allocTypes = [ + 'running', + 'pending', + 'failed', + // 'unknown', + // 'lost', + // 'queued', + // 'complete', + 'unplaced', + ].map((type) => { + return { + label: type, + }; + }); + + @tracked oldVersionAllocBlockIDs = []; + + // Called via did-insert; sets a static array of "outgoing" + // allocations we can track throughout a deployment + establishOldAllocBlockIDs() { + this.oldVersionAllocBlockIDs = this.job.allocations.filter( + (a) => + a.clientStatus === 'running' && + a.jobVersion !== this.deployment.get('versionNumber') + ); + } + + @task(function* () { + try { + yield this.job.latestDeployment.content.promote(); + } catch (err) { + this.handleError({ + title: 'Could Not Promote Deployment', + description: messageFromAdapterError(err, 'promote deployments'), + }); + } + }) + promote; + + @task(function* () { + try { + yield this.job.latestDeployment.content.fail(); + } catch (err) { + this.handleError({ + title: 'Could Not Fail Deployment', + description: messageFromAdapterError(err, 'fail deployments'), + }); + } + }) + fail; + + @alias('job.latestDeployment') deployment; + @alias('deployment.desiredTotal') desiredTotal; + + get oldVersionAllocBlocks() { + return this.job.allocations + .filter((allocation) => this.oldVersionAllocBlockIDs.includes(allocation)) + .reduce((alloGroups, currentAlloc) => { + const status = currentAlloc.clientStatus; + + if (!alloGroups[status]) { + alloGroups[status] = { + healthy: { nonCanary: [] }, + unhealthy: { nonCanary: [] }, + }; + } + alloGroups[status].healthy.nonCanary.push(currentAlloc); + + return alloGroups; + }, {}); + } + + get newVersionAllocBlocks() { + let availableSlotsToFill = this.desiredTotal; + let allocationsOfDeploymentVersion = this.job.allocations.filter( + (a) => a.jobVersion === this.deployment.get('versionNumber') + ); + + let allocationCategories = this.allocTypes.reduce((categories, type) => { + categories[type.label] = { + healthy: { canary: [], nonCanary: [] }, + unhealthy: { canary: [], nonCanary: [] }, + }; + return categories; + }, {}); + + for (let alloc of allocationsOfDeploymentVersion) { + if (availableSlotsToFill <= 0) { + break; + } + let status = alloc.clientStatus; + let health = alloc.isHealthy ? 'healthy' : 'unhealthy'; + let canary = alloc.isCanary ? 'canary' : 'nonCanary'; + + if (allocationCategories[status]) { + allocationCategories[status][health][canary].push(alloc); + availableSlotsToFill--; + } + } + + // Fill unplaced slots if availableSlotsToFill > 0 + if (availableSlotsToFill > 0) { + allocationCategories['unplaced'] = { + healthy: { canary: [], nonCanary: [] }, + unhealthy: { canary: [], nonCanary: [] }, + }; + allocationCategories['unplaced']['healthy']['nonCanary'] = Array( + availableSlotsToFill + ) + .fill() + .map(() => { + return { clientStatus: 'unplaced' }; + }); + } + + return allocationCategories; + } + + get newRunningHealthyAllocBlocks() { + return [ + ...this.newVersionAllocBlocks['running']['healthy']['canary'], + ...this.newVersionAllocBlocks['running']['healthy']['nonCanary'], + ]; + } + + // #region legend + get newAllocsByStatus() { + return Object.entries(this.newVersionAllocBlocks).reduce( + (counts, [status, healthStatusObj]) => { + counts[status] = Object.values(healthStatusObj) + .flatMap((canaryStatusObj) => Object.values(canaryStatusObj)) + .flatMap((canaryStatusArray) => canaryStatusArray).length; + return counts; + }, + {} + ); + } + + get newAllocsByCanary() { + return Object.values(this.newVersionAllocBlocks) + .flatMap((healthStatusObj) => Object.values(healthStatusObj)) + .flatMap((canaryStatusObj) => Object.entries(canaryStatusObj)) + .reduce((counts, [canaryStatus, items]) => { + counts[canaryStatus] = (counts[canaryStatus] || 0) + items.length; + return counts; + }, {}); + } + + get newAllocsByHealth() { + return { + healthy: this.newRunningHealthyAllocBlocks.length, + 'health unknown': + this.totalAllocs - this.newRunningHealthyAllocBlocks.length, + }; + } + // #endregion legend + + get oldRunningHealthyAllocBlocks() { + return this.oldVersionAllocBlocks.running?.healthy?.nonCanary || []; + } + get oldCompleteHealthyAllocBlocks() { + return this.oldVersionAllocBlocks.complete?.healthy?.nonCanary || []; + } + + // TODO: eventually we will want this from a new property on a job. + // TODO: consolidate w/ the one in steady.js + get totalAllocs() { + // v----- Experimental method: Count all allocs. Good for testing but not a realistic representation of "Desired" + // return this.allocTypes.reduce((sum, type) => sum + this.args.job[type.property], 0); + + // v----- Realistic method: Tally a job's task groups' "count" property + return this.args.job.taskGroups.reduce((sum, tg) => sum + tg.count, 0); + } +} diff --git a/ui/app/components/job-status/panel/steady.hbs b/ui/app/components/job-status/panel/steady.hbs new file mode 100644 index 000000000..b76221ef7 --- /dev/null +++ b/ui/app/components/job-status/panel/steady.hbs @@ -0,0 +1,61 @@ +
+
+

Status

+
+ + +
+
+
+ {{#if (eq @statusMode "historical")}} + + {{else}} +

{{@job.runningAllocs}}/{{this.totalAllocs}} Allocations Running

+ + +
+ + {{#each this.allocTypes as |type|}} + + + {{get (get (get (get this.allocBlocks type.label) 'healthy') 'nonCanary') "length"}} {{capitalize type.label}} + + {{/each}} + + +
+

Versions

+
    + {{#each-in this.versions as |version allocs|}} +
  • + + + + +
  • + {{/each-in}} +
+
+ +
+ {{/if}} +
+
diff --git a/ui/app/components/job-status/panel/steady.js b/ui/app/components/job-status/panel/steady.js new file mode 100644 index 000000000..77a391d2a --- /dev/null +++ b/ui/app/components/job-status/panel/steady.js @@ -0,0 +1,87 @@ +// @ts-check +import Component from '@glimmer/component'; +import { alias } from '@ember/object/computed'; + +export default class JobStatusPanelSteadyComponent extends Component { + @alias('args.job') job; + + // Build note: allocTypes order matters! We will fill up to 100% of totalAllocs in this order. + allocTypes = [ + 'running', + 'pending', + 'failed', + // 'unknown', + // 'lost', + // 'queued', + // 'complete', + 'unplaced', + ].map((type) => { + return { + label: type, + }; + }); + + get allocBlocks() { + let availableSlotsToFill = this.totalAllocs; + // Only fill up to 100% of totalAllocs. Once we've filled up, we can stop counting. + let allocationsOfShowableType = this.allocTypes.reduce((blocks, type) => { + const jobAllocsOfType = this.args.job.allocations.filterBy( + 'clientStatus', + type.label + ); + if (availableSlotsToFill > 0) { + blocks[type.label] = { + healthy: { + nonCanary: Array( + Math.min(availableSlotsToFill, jobAllocsOfType.length) + ) + .fill() + .map((_, i) => { + return jobAllocsOfType[i]; + }), + }, + }; + availableSlotsToFill -= blocks[type.label].healthy.nonCanary.length; + } else { + blocks[type.label] = { healthy: { nonCanary: [] } }; + } + return blocks; + }, {}); + if (availableSlotsToFill > 0) { + allocationsOfShowableType['unplaced'] = { + healthy: { + nonCanary: Array(availableSlotsToFill) + .fill() + .map(() => { + return { clientStatus: 'unplaced' }; + }), + }, + }; + } + return allocationsOfShowableType; + } + + // TODO: eventually we will want this from a new property on a job. + get totalAllocs() { + // v----- Experimental method: Count all allocs. Good for testing but not a realistic representation of "Desired" + // return this.allocTypes.reduce((sum, type) => sum + this.args.job[type.property], 0); + + // v----- Realistic method: Tally a job's task groups' "count" property + return this.args.job.taskGroups.reduce((sum, tg) => sum + tg.count, 0); + } + + get versions() { + return Object.values(this.allocBlocks) + .flatMap((allocType) => Object.values(allocType)) + .flatMap((allocHealth) => Object.values(allocHealth)) + .flatMap((allocCanary) => Object.values(allocCanary)) + .map((a) => (!isNaN(a?.jobVersion) ? a.jobVersion : 'pending')) // "starting" allocs, and possibly others, do not yet have a jobVersion + .reduce( + (result, item) => ({ + ...result, + [item]: [...(result[item] || []), item], + }), + [] + ); + } +} diff --git a/ui/app/components/job-status/update-params.hbs b/ui/app/components/job-status/update-params.hbs new file mode 100644 index 000000000..e9213dd6d --- /dev/null +++ b/ui/app/components/job-status/update-params.hbs @@ -0,0 +1,36 @@ + + {{did-insert trigger.fns.do}} + +
+

Update Params

+ + + {{#if (and trigger.data.isSuccess (not trigger.data.isError))}} +
    + {{#each this.updateParamGroups as |group|}} +
  • + Group "{{group.name}}" +
      + {{#each-in group.update as |k v|}} +
    • + {{k}} + {{v}} +
    • + {{/each-in}} +
    +
  • + {{/each}} +
+ {{/if}} + + {{#if trigger.data.isBusy}} + Loading Parameters + {{/if}} + + {{#if trigger.data.isError}} + Error loading parameters + {{/if}} + +
+
+
diff --git a/ui/app/components/job-status/update-params.js b/ui/app/components/job-status/update-params.js new file mode 100644 index 000000000..79436a7db --- /dev/null +++ b/ui/app/components/job-status/update-params.js @@ -0,0 +1,77 @@ +// @ts-check +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import formatDuration from 'nomad-ui/utils/format-duration'; + +/** + * @typedef {Object} DefinitionUpdateStrategy + * @property {boolean} AutoPromote + * @property {boolean} AutoRevert + * @property {number} Canary + * @property {number} MaxParallel + * @property {string} HealthCheck + * @property {number} MinHealthyTime + * @property {number} HealthyDeadline + * @property {number} ProgressDeadline + * @property {number} Stagger + */ + +/** + * @typedef {Object} DefinitionTaskGroup + * @property {string} Name + * @property {number} Count + * @property {DefinitionUpdateStrategy} Update + */ + +/** + * @typedef {Object} JobDefinition + * @property {string} ID + * @property {DefinitionUpdateStrategy} Update + * @property {DefinitionTaskGroup[]} TaskGroups + */ + +const PARAMS_REQUIRING_CONVERSION = [ + 'HealthyDeadline', + 'MinHealthyTime', + 'ProgressDeadline', + 'Stagger', +]; + +export default class JobStatusUpdateParamsComponent extends Component { + @service notifications; + + /** + * @type {JobDefinition} + */ + @tracked rawDefinition = null; + + get updateParamGroups() { + if (!this.rawDefinition) { + return null; + } + return this.rawDefinition.TaskGroups.map((tg) => ({ + name: tg.Name, + update: Object.keys(tg.Update || {}).reduce((newUpdateObj, key) => { + newUpdateObj[key] = PARAMS_REQUIRING_CONVERSION.includes(key) + ? formatDuration(tg.Update[key]) + : tg.Update[key]; + return newUpdateObj; + }, {}), + })); + } + + @action onError({ Error }) { + const error = Error.errors[0].title || 'Error fetching job parameters'; + this.notifications.add({ + title: 'Could not fetch job definition', + message: error, + color: 'critical', + }); + } + + @action async fetchJobDefinition() { + this.rawDefinition = await this.args.job.fetchRawDefinition(); + } +} diff --git a/ui/app/controllers/jobs/job/allocations.js b/ui/app/controllers/jobs/job/allocations.js index ce5cb180a..f3abbacf9 100644 --- a/ui/app/controllers/jobs/job/allocations.js +++ b/ui/app/controllers/jobs/job/allocations.js @@ -46,12 +46,16 @@ export default class AllocationsController extends Controller.extend( { qpTaskGroup: 'taskGroup', }, + { + qpVersion: 'version', + }, 'activeTask', ]; qpStatus = ''; qpClient = ''; qpTaskGroup = ''; + qpVersion = ''; currentPage = 1; pageSize = 25; activeTask = null; @@ -75,10 +79,16 @@ export default class AllocationsController extends Controller.extend( 'allocations.[]', 'selectionStatus', 'selectionClient', - 'selectionTaskGroup' + 'selectionTaskGroup', + 'selectionVersion' ) get filteredAllocations() { - const { selectionStatus, selectionClient, selectionTaskGroup } = this; + const { + selectionStatus, + selectionClient, + selectionTaskGroup, + selectionVersion, + } = this; return this.allocations.filter((alloc) => { if ( @@ -99,6 +109,12 @@ export default class AllocationsController extends Controller.extend( ) { return false; } + if ( + selectionVersion.length && + !selectionVersion.includes(alloc.jobVersion) + ) { + return false; + } return true; }); } @@ -110,6 +126,7 @@ export default class AllocationsController extends Controller.extend( @selection('qpStatus') selectionStatus; @selection('qpClient') selectionClient; @selection('qpTaskGroup') selectionTaskGroup; + @selection('qpVersion') selectionVersion; @action gotoAllocation(allocation) { @@ -163,6 +180,24 @@ export default class AllocationsController extends Controller.extend( return taskGroups.sort().map((tg) => ({ key: tg, label: tg })); } + @computed('model.allocations.[]', 'selectionVersion') + get optionsVersions() { + const versions = Array.from( + new Set(this.model.allocations.mapBy('jobVersion')) + ).compact(); + + // Update query param when the list of versions changes. + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.set( + 'qpVersion', + serialize(intersection(versions, this.selectionVersion)) + ); + }); + + return versions.sort((a, b) => a - b).map((v) => ({ key: v, label: v })); + } + setFacetQueryParam(queryParam, selection) { this.set(queryParam, serialize(selection)); } diff --git a/ui/app/controllers/jobs/job/index.js b/ui/app/controllers/jobs/job/index.js index 2dec0dc05..e5032ca35 100644 --- a/ui/app/controllers/jobs/job/index.js +++ b/ui/app/controllers/jobs/job/index.js @@ -3,12 +3,14 @@ * SPDX-License-Identifier: MPL-2.0 */ +// @ts-check import Controller from '@ember/controller'; import { alias } from '@ember/object/computed'; import { inject as service } from '@ember/service'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; import classic from 'ember-classic-decorator'; import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; @classic export default class IndexController extends Controller.extend( WithNamespaceResetting @@ -26,6 +28,7 @@ export default class IndexController extends Controller.extend( sortDescending: 'desc', }, 'activeTask', + 'statusMode', ]; currentPage = 1; @@ -34,14 +37,29 @@ export default class IndexController extends Controller.extend( sortProperty = 'name'; sortDescending = false; - activeTask = null; + + @tracked activeTask = null; + + /** + * @type {('current'|'historical')} + */ + @tracked + statusMode = 'current'; @action setActiveTaskQueryParam(task) { if (task) { - this.set('activeTask', `${task.allocation.id}-${task.name}`); + this.activeTask = `${task.allocation.id}-${task.name}`; } else { - this.set('activeTask', null); + this.activeTask = null; } } + + /** + * @param {('current'|'historical')} mode + */ + @action + setStatusMode(mode) { + this.statusMode = mode; + } } diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js index d949a46d8..0848308f4 100644 --- a/ui/app/models/allocation.js +++ b/ui/app/models/allocation.js @@ -46,6 +46,15 @@ export default class Allocation extends Model { @attr('string') clientStatus; @attr('string') desiredStatus; + @attr() deploymentStatus; + + get isCanary() { + return this.deploymentStatus?.Canary; + } + + get isHealthy() { + return this.deploymentStatus?.Healthy; + } @attr healthChecks; diff --git a/ui/app/styles/charts/colors.scss b/ui/app/styles/charts/colors.scss index f84615d92..54c1490fb 100644 --- a/ui/app/styles/charts/colors.scss +++ b/ui/app/styles/charts/colors.scss @@ -6,7 +6,7 @@ $queued: $grey-lighter; $starting: $grey-lighter; $running: $primary; -$complete: $nomad-green-dark; +$complete: $nomad-green-pale; $failed: $danger; $lost: $dark; $not-scheduled: $blue-200; diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index eba7851a2..22538dbed 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -58,3 +58,4 @@ @import './components/authorization'; @import './components/policies'; @import './components/metadata-editor'; +@import './components/job-status-panel'; diff --git a/ui/app/styles/components/job-status-panel.scss b/ui/app/styles/components/job-status-panel.scss new file mode 100644 index 000000000..fb939f73a --- /dev/null +++ b/ui/app/styles/components/job-status-panel.scss @@ -0,0 +1,442 @@ +.job-status-panel { + // #region layout + &.steady-state.current-state .boxed-section-body { + display: grid; + grid-template-areas: + 'title' + 'allocation-status-row' + 'legend-and-summary'; + gap: 1rem; + grid-auto-columns: 100%; + + & > h3 { + grid-area: title; + margin: 0; + } + + & > .allocation-status-row { + grid-area: allocation-status-row; + } + } + + &.active-deployment .boxed-section-body { + display: grid; + grid-template-areas: + 'deployment-allocations' + 'legend-and-summary' + 'history-and-params'; + gap: 1rem; + grid-auto-columns: 100%; + + & > .deployment-allocations { + grid-area: deployment-allocations; + display: grid; + gap: 1rem; + grid-auto-columns: 100%; + + & > h4 { + margin-bottom: -0.5rem; + } + } + + & > .history-and-params { + grid-area: history-and-params; + } + } + + .legend-and-summary { + // grid-area: legend-and-summary; + // TODO: may revisit this grid-area later, but is currently used in 2 competing ways + display: grid; + gap: 0.5rem; + grid-template-columns: 50% 50%; + + legend { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + } + .versions { + display: grid; + gap: 0.5rem; + & > ul { + display: grid; + grid-template-columns: repeat(auto-fit, 100px); + gap: 0.5rem; + & > li { + white-space: nowrap; + & a { + text-decoration: none; + } + } + } + } + } + + // #endregion layout + + .select-mode { + border: 1px solid $grey-blue; + background: rgba(0, 0, 0, 0.05); + border-radius: 2px; + display: grid; + gap: 0.5rem; + grid-template-columns: 1fr 1fr; + padding: 0.25rem 0.5rem; + margin-left: 1rem; + + button { + height: auto; + padding: 0 0.5rem; + background: transparent; + transition: 0.1s; + + &:hover { + background: rgba(255, 255, 255, 0.5); + } + + &.is-active { + background: $white; + } + } + } + + .running-allocs-title { + strong { + font-weight: 800; + } + } + + .ungrouped-allocs { + display: grid; + gap: 10px; + grid-auto-flow: column; + grid-auto-columns: 32px; + + & > .represented-allocation { + width: 32px; + } + } + + .alloc-status-summaries { + display: flex; + height: 32px; + gap: 1.5rem; + + .allocation-status-block { + display: grid; + grid-template-columns: auto 50px; + gap: 10px; + + &.rest-only { + grid-template-columns: auto; + } + + & > .ungrouped-allocs { + display: grid; + grid-auto-flow: column; + gap: 10px; + grid-auto-columns: unset; + & > .represented-allocation { + width: 32px; + } + } + + .represented-allocation.rest { + // TODO: we eventually want to establish a minimum width here. However, we need to also include this in the allocation-status-block width computation. + font-size: 0.8rem; + font-weight: bold; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); + width: 100%; + + & > .rest-count { + position: relative; + z-index: 2; + } + + &.unplaced { + color: black; + } + } + } + } + + .represented-allocation { + background: $green; + border-radius: 4px; + height: 32px; + width: 32px; + color: white; + position: relative; + display: grid; + align-content: center; + justify-content: center; + + $queued: $grey; + $pending: $grey-lighter; + $running: $primary; + $complete: $nomad-green-pale; + $failed: $danger; + $lost: $dark; + + // Client Statuses + &.running { + background: $running; + } + &.failed { + background: $failed; + } + &.unknown { + background: $unknown; + } + &.queued { + background: $queued; + } + &.complete { + background: $complete; + color: black; + } + &.pending { + background: $pending; + color: black; + position: relative; + overflow: hidden; + + &:after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(-60deg, $pending, #eee, $pending); + animation: shimmer 2s ease-in-out infinite; + } + } + &.lost { + background: $lost; + } + + &.unplaced { + background: $grey-lighter; + position: relative; + overflow: hidden; + + &:before { + background: linear-gradient(-60deg, $pending, #eee, $pending); + animation: shimmer 2s ease-in-out infinite; + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + &:after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: calc(100% - 4px); + height: calc(100% - 4px); + margin: 2px; + background: white; + border-radius: 3px; + } + } + + &.legend-example { + background: #eee; + } + + // Health Statuses + + .alloc-health-indicator { + width: 100%; + height: 100%; + position: absolute; + display: grid; + align-content: center; + justify-content: center; + } + + &.running { + .alloc-health-indicator { + position: absolute; + width: 100%; + height: 100%; + display: grid; + align-content: center; + justify-content: center; + } + &.rest .alloc-health-indicator { + top: -7px; + right: -7px; + border-radius: 20px; + background: white; + box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.5); + width: 20px; + height: 20px; + box-sizing: border-box; + transform: scale(0.75); + } + } + + // Canary Status + &.canary > .alloc-canary-indicator { + overflow: hidden; + width: 16px; + height: 16px; + position: absolute; + bottom: 0; + left: 0; + border-radius: 4px; + + &:after { + content: ''; + position: absolute; + left: -8px; + bottom: -8px; + width: 16px; + height: 16px; + transform: rotate(45deg); + background-color: $orange; + } + } + } + + .legend-item .represented-allocation .flight-icon { + animation: none; + } + + & > .boxed-section-body > .deployment-allocations { + margin-bottom: 1rem; + } + + .legend-item { + display: grid; + gap: 0.5rem; + grid-template-columns: auto 1fr; + + &.faded { + opacity: 0.5; + } + + .represented-allocation { + width: 20px; + height: 20px; + animation: none; + &:before, + &:after { + animation: none; + } + } + } + + .history-and-params { + display: grid; + grid-template-columns: 70% auto; + gap: 1rem; + margin-top: 1rem; + } + + .deployment-history { + & > header { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 1rem; + margin-bottom: 1rem; + align-items: end; + & > .search-box { + max-width: unset; + } + } + & > ol { + max-height: 300px; + overflow-y: auto; + } + & > ol > li { + @for $i from 1 through 50 { + &:nth-child(#{$i}) { + animation-name: historyItemSlide; + animation-duration: 0.2s; + animation-fill-mode: both; + animation-delay: 0.1s + (0.05 * $i); + } + + &:nth-child(#{$i}) > div { + animation-name: historyItemShine; + animation-duration: 1s; + animation-fill-mode: both; + animation-delay: 0.1s + (0.05 * $i); + } + } + + & > div { + gap: 0.5rem; + } + &.error > div { + border: 1px solid $danger; + background: rgba($danger, 0.1); + } + } + } + + .update-parameters { + & > code { + max-height: 300px; + overflow-y: auto; + display: block; + } + ul, + span.notification { + display: block; + background: #1a2633; + padding: 1rem; + color: white; + .key { + color: #1caeff; + &:after { + content: '='; + color: white; + margin-left: 0.5rem; + } + } + .value { + color: #06d092; + } + } + } +} + +@keyframes historyItemSlide { + from { + opacity: 0; + top: -40px; + } + to { + opacity: 1; + top: 0px; + } +} + +@keyframes historyItemShine { + from { + box-shadow: inset 0 0 0 100px rgba(255, 200, 0, 0.2); + } + to { + box-shadow: inset 0 0 0 100px rgba(255, 200, 0, 0); + } +} + +@keyframes shimmer { + 0% { + transform: translate3d(-100%, 0, 0); + } + 30% { + transform: translate3d(100%, 0, 0); + } + 100% { + transform: translate3d(100%, 0, 0); + } +} diff --git a/ui/app/styles/core/tag.scss b/ui/app/styles/core/tag.scss index e81657914..1583cf79e 100644 --- a/ui/app/styles/core/tag.scss +++ b/ui/app/styles/core/tag.scss @@ -30,8 +30,8 @@ } &.is-complete { - background: $nomad-green-dark; - color: findColorInvert($nomad-green-dark); + background: $nomad-green-pale; + color: findColorInvert($nomad-green-pale); } &.is-error { @@ -87,13 +87,12 @@ width: 1rem; } - $tagPadding: 0.75em; &.canary { overflow: hidden; &:before { - content: "Canary"; + content: 'Canary'; background-color: $blue-light; color: $black; line-height: 1.5em; diff --git a/ui/app/styles/utils/product-colors.scss b/ui/app/styles/utils/product-colors.scss index 663c3cd93..5385071af 100644 --- a/ui/app/styles/utils/product-colors.scss +++ b/ui/app/styles/utils/product-colors.scss @@ -19,5 +19,6 @@ $vagrant-blue-dark: #104eb2; $nomad-green: #25ba81; $nomad-green-dark: #1d9467; $nomad-green-darker: #16704d; +$nomad-green-pale: #d9f0e6; $serf-red: #dd4e58; diff --git a/ui/app/templates/components/job-page.hbs b/ui/app/templates/components/job-page.hbs index 24f681ce2..e2e750a2f 100644 --- a/ui/app/templates/components/job-page.hbs +++ b/ui/app/templates/components/job-page.hbs @@ -33,6 +33,12 @@ "job-page/parts/job-client-status-summary" job=@job ) Children=(component "job-page/parts/children" job=@job) + + StatusPanel=(component + "job-status/panel" job=@job + handleError=this.handleError + ) + ) ) }} \ No newline at end of file diff --git a/ui/app/templates/components/job-page/parts/summary-chart.hbs b/ui/app/templates/components/job-page/parts/summary-chart.hbs new file mode 100644 index 000000000..b42df7cc3 --- /dev/null +++ b/ui/app/templates/components/job-page/parts/summary-chart.hbs @@ -0,0 +1,62 @@ +{{#if @job.hasChildren}} + +
    + {{#each chart.data as |datum index|}} +
  1. + +
  2. + {{/each}} +
+
+{{else}} + +
    + {{#each chart.data as |datum index|}} +
  1. + {{#if (and (gt datum.value 0) datum.legendLink)}} + + + + {{else}} + + {{/if}} +
  2. + {{/each}} +
+
+{{/if}} diff --git a/ui/app/templates/components/job-page/parts/summary.hbs b/ui/app/templates/components/job-page/parts/summary.hbs index 9806129c2..53cb91efd 100644 --- a/ui/app/templates/components/job-page/parts/summary.hbs +++ b/ui/app/templates/components/job-page/parts/summary.hbs @@ -51,67 +51,6 @@ - {{#if a.item.hasChildren}} - -
    - {{#each chart.data as |datum index|}} -
  1. - -
  2. - {{/each}} -
-
- {{else}} - -
    - {{#each chart.data as |datum index|}} -
  1. - {{#if (and (gt datum.value 0) datum.legendLink)}} - - - - {{else}} - - {{/if}} -
  2. - {{/each}} -
-
- {{/if}} +
\ No newline at end of file diff --git a/ui/app/templates/components/job-page/service.hbs b/ui/app/templates/components/job-page/service.hbs index 013fe9322..1420961e7 100644 --- a/ui/app/templates/components/job-page/service.hbs +++ b/ui/app/templates/components/job-page/service.hbs @@ -9,8 +9,9 @@ - + + {{!-- latestDeployment only included here for visual comparison during build-out --}} diff --git a/ui/app/templates/jobs/job/allocations.hbs b/ui/app/templates/jobs/job/allocations.hbs index ab28b835e..b6613ba38 100644 --- a/ui/app/templates/jobs/job/allocations.hbs +++ b/ui/app/templates/jobs/job/allocations.hbs @@ -38,6 +38,13 @@ @selection={{this.selectionTaskGroup}} @onSelect={{action this.setFacetQueryParam "qpTaskGroup"}} /> + diff --git a/ui/app/templates/jobs/job/index.hbs b/ui/app/templates/jobs/job/index.hbs index bf7fa8c6a..0ab8e5f01 100644 --- a/ui/app/templates/jobs/job/index.hbs +++ b/ui/app/templates/jobs/job/index.hbs @@ -12,4 +12,6 @@ currentPage=this.currentPage activeTask=this.activeTask setActiveTaskQueryParam=this.setActiveTaskQueryParam + statusMode=this.statusMode + setStatusMode=this.setStatusMode }} \ No newline at end of file diff --git a/ui/mirage/factories/deployment.js b/ui/mirage/factories/deployment.js index 6af73f149..6313858e9 100644 --- a/ui/mirage/factories/deployment.js +++ b/ui/mirage/factories/deployment.js @@ -8,13 +8,20 @@ import faker from 'nomad-ui/mirage/faker'; import { provide } from '../utils'; const UUIDS = provide(100, faker.random.uuid.bind(faker.random)); -const DEPLOYMENT_STATUSES = ['running', 'successful', 'paused', 'failed', 'cancelled']; +const DEPLOYMENT_STATUSES = [ + 'running', + 'successful', + 'paused', + 'failed', + 'cancelled', +]; export default Factory.extend({ - id: i => (i / 100 >= 1 ? `${UUIDS[i]}-${i}` : UUIDS[i]), + id: (i) => (i / 100 >= 1 ? `${UUIDS[i]}-${i}` : UUIDS[i]), jobId: null, versionNumber: null, + groupDesiredTotal: null, status: () => faker.helpers.randomize(DEPLOYMENT_STATUSES), statusDescription: () => faker.lorem.sentence(), @@ -29,14 +36,20 @@ export default Factory.extend({ afterCreate(deployment, server) { const job = server.db.jobs.find(deployment.jobId); - const groups = job.taskGroupIds.map(id => - server.create('deployment-task-group-summary', { + const groups = job.taskGroupIds.map((id) => { + let summary = server.create('deployment-task-group-summary', { deployment, name: server.db.taskGroups.find(id).name, desiredCanaries: 1, promoted: false, - }) - ); + }); + if (deployment.groupDesiredTotal) { + summary.update({ + desiredTotal: deployment.groupDesiredTotal, + }); + } + return summary; + }); deployment.update({ deploymentTaskGroupSummaryIds: groups.mapBy('id'), diff --git a/ui/mirage/factories/job-summary.js b/ui/mirage/factories/job-summary.js index 10446a9db..62bd0954f 100644 --- a/ui/mirage/factories/job-summary.js +++ b/ui/mirage/factories/job-summary.js @@ -28,6 +28,40 @@ export default Factory.extend({ return summary; }, {}); }, + afterCreate(jobSummary, server) { + // Update the summary alloc types to match server allocations with same job ID + const jobAllocs = server.db.allocations.where({ + jobId: jobSummary.jobId, + }); + let summary = jobSummary.groupNames.reduce((summary, group) => { + summary[group] = { + Queued: jobAllocs + .filterBy('taskGroup', group) + .filterBy('clientStatus', 'pending').length, + Complete: jobAllocs + .filterBy('taskGroup', group) + .filterBy('clientStatus', 'complete').length, + Failed: jobAllocs + .filterBy('taskGroup', group) + .filterBy('clientStatus', 'failed').length, + Running: jobAllocs + .filterBy('taskGroup', group) + .filterBy('clientStatus', 'running').length, + Starting: jobAllocs + .filterBy('taskGroup', group) + .filterBy('clientStatus', 'starting').length, + Lost: jobAllocs + .filterBy('taskGroup', group) + .filterBy('clientStatus', 'lost').length, + Unknown: jobAllocs + .filterBy('taskGroup', group) + .filterBy('clientStatus', 'unknown').length, + }; + return summary; + }, {}); + + jobSummary.update({ summary }); + }, }), withChildren: trait({ diff --git a/ui/mirage/factories/job.js b/ui/mirage/factories/job.js index c76d71a7a..e61b914db 100644 --- a/ui/mirage/factories/job.js +++ b/ui/mirage/factories/job.js @@ -222,6 +222,7 @@ export default Factory.extend({ withTaskServices: job.withTaskServices, createRecommendations: job.createRecommendations, shallow: job.shallow, + allocStatusDistribution: job.allocStatusDistribution, }; if (job.groupTaskCount) { diff --git a/ui/mirage/factories/task-event.js b/ui/mirage/factories/task-event.js index 4855c17fc..34bd967ef 100644 --- a/ui/mirage/factories/task-event.js +++ b/ui/mirage/factories/task-event.js @@ -17,5 +17,5 @@ export default Factory.extend({ exitCode: () => null, time: () => faker.date.past(2 / 365, REF_TIME) * 1000000, - displayMessage: () => faker.lorem.sentence(), + message: () => faker.lorem.sentence(), }); diff --git a/ui/mirage/factories/task-group.js b/ui/mirage/factories/task-group.js index acd790995..ee064597f 100644 --- a/ui/mirage/factories/task-group.js +++ b/ui/mirage/factories/task-group.js @@ -116,28 +116,84 @@ export default Factory.extend({ }); if (group.createAllocations) { - Array(group.count) - .fill(null) - .forEach((_, i) => { - const props = { - jobId: group.job.id, - namespace: group.job.namespace, - taskGroup: group.name, - name: `${group.name}.[${i}]`, - rescheduleSuccess: group.withRescheduling - ? faker.random.boolean() - : null, - rescheduleAttempts: group.withRescheduling - ? faker.random.number({ min: 1, max: 5 }) - : 0, - }; - - if (group.withRescheduling) { - server.create('allocation', 'rescheduled', props); - } else { - server.create('allocation', props); - } + if (group.allocStatusDistribution) { + const statusProbabilities = group.allocStatusDistribution || { + running: 0.6, + failed: 0.05, + unknown: 0.25, + lost: 0.1, + }; + + const totalAllocations = group.count; + const allocationsByStatus = {}; + + Object.entries(statusProbabilities).forEach(([status, prob]) => { + allocationsByStatus[status] = Math.round(totalAllocations * prob); }); + + let currentStatusIndex = 0; + const statusKeys = Object.keys(allocationsByStatus); + + Array(totalAllocations) + .fill(null) + .forEach((_, i) => { + let clientStatus; + + while (allocationsByStatus[statusKeys[currentStatusIndex]] === 0) { + currentStatusIndex++; + } + + clientStatus = statusKeys[currentStatusIndex]; + allocationsByStatus[clientStatus]--; + + const props = { + jobId: group.job.id, + namespace: group.job.namespace, + taskGroup: group.name, + name: `${group.name}.[${i}]`, + rescheduleSuccess: group.withRescheduling + ? faker.random.boolean() + : null, + rescheduleAttempts: group.withRescheduling + ? faker.random.number({ min: 1, max: 5 }) + : 0, + clientStatus, + deploymentStatus: { + Canary: false, + Healthy: false, + }, + }; + + if (group.withRescheduling) { + server.create('allocation', 'rescheduled', props); + } else { + server.create('allocation', props); + } + }); + } else { + Array(group.count) + .fill(null) + .forEach((_, i) => { + const props = { + jobId: group.job.id, + namespace: group.job.namespace, + taskGroup: group.name, + name: `${group.name}.[${i}]`, + rescheduleSuccess: group.withRescheduling + ? faker.random.boolean() + : null, + rescheduleAttempts: group.withRescheduling + ? faker.random.number({ min: 1, max: 5 }) + : 0, + }; + + if (group.withRescheduling) { + server.create('allocation', 'rescheduled', props); + } else { + server.create('allocation', props); + } + }); + } } if (group.withServices) { diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index ca1ff45a0..c9d1140f3 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -73,6 +73,94 @@ function smallCluster(server) { id: 'service-haver', namespaceId: 'default', }); + server.create('job', { + createAllocations: true, + groupTaskCount: 150, + shallow: true, + allocStatusDistribution: { + running: 0.5, + failed: 0.05, + unknown: 0.2, + lost: 0.1, + complete: 0.1, + pending: 0.05, + }, + name: 'mixed-alloc-job', + id: 'mixed-alloc-job', + namespaceId: 'default', + type: 'service', + activeDeployment: true, + }); + + //#region Active Deployment + + const activelyDeployingJobGroups = 2; + const activelyDeployingTasksPerGroup = 100; + + const activelyDeployingJob = server.create('job', { + createAllocations: true, + groupTaskCount: activelyDeployingTasksPerGroup, + shallow: true, + resourceSpec: Array(activelyDeployingJobGroups).fill(['M: 257, C: 500']), + noDeployments: true, // manually created below + activeDeployment: true, + allocStatusDistribution: { + running: 0.6, + failed: 0.05, + unknown: 0.05, + lost: 0, + complete: 0, + pending: 0.3, + }, + name: 'actively-deploying-job', + id: 'actively-deploying-job', + namespaceId: 'default', + type: 'service', + }); + + server.create('deployment', false, 'active', { + jobId: activelyDeployingJob.id, + groupDesiredTotal: activelyDeployingTasksPerGroup, + versionNumber: 1, + status: 'running', + }); + server.createList('allocation', 25, { + jobId: activelyDeployingJob.id, + jobVersion: 0, + clientStatus: 'running', + }); + + // Manipulate the above job to show a nice distribution of running, canary, etc. allocs + let activelyDeployingJobAllocs = server.schema.allocations + .all() + .filter((a) => a.jobId === activelyDeployingJob.id); + activelyDeployingJobAllocs.models + .filter((a) => a.clientStatus === 'running') + .slice(0, 10) + .forEach((a) => + a.update({ deploymentStatus: { Healthy: false, Canary: true } }) + ); + activelyDeployingJobAllocs.models + .filter((a) => a.clientStatus === 'running') + .slice(10, 20) + .forEach((a) => + a.update({ deploymentStatus: { Healthy: true, Canary: true } }) + ); + activelyDeployingJobAllocs.models + .filter((a) => a.clientStatus === 'running') + .slice(20, 65) + .forEach((a) => + a.update({ deploymentStatus: { Healthy: true, Canary: false } }) + ); + activelyDeployingJobAllocs.models + .filter((a) => a.clientStatus === 'pending') + .slice(0, 10) + .forEach((a) => + a.update({ deploymentStatus: { Healthy: true, Canary: true } }) + ); + + //#endregion Active Deployment + server.createList('allocFile', 5); server.create('allocFile', 'dir', { depth: 2 }); server.createList('csi-plugin', 2); diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js index 22858d715..0e277f30f 100644 --- a/ui/tests/acceptance/allocation-detail-test.js +++ b/ui/tests/acceptance/allocation-detail-test.js @@ -216,7 +216,7 @@ module('Acceptance | allocation detail', function (hooks) { assert.equal(taskRow.name, task.name, 'Name'); assert.equal(taskRow.state, task.state, 'State'); - assert.equal(taskRow.message, event.displayMessage, 'Event Message'); + assert.equal(taskRow.message, event.message, 'Event Message'); assert.equal( taskRow.time, moment(event.time / 1000000).format("MMM DD, 'YY HH:mm:ss ZZ"), diff --git a/ui/tests/acceptance/job-status-panel-test.js b/ui/tests/acceptance/job-status-panel-test.js new file mode 100644 index 000000000..653b061b0 --- /dev/null +++ b/ui/tests/acceptance/job-status-panel-test.js @@ -0,0 +1,487 @@ +// @ts-check +import { module, test } from 'qunit'; +import { setupApplicationTest } from 'ember-qunit'; + +import { + click, + visit, + find, + findAll, + fillIn, + triggerEvent, +} from '@ember/test-helpers'; + +import { setupMirage } from 'ember-cli-mirage/test-support'; +import faker from 'nomad-ui/mirage/faker'; +import percySnapshot from '@percy/ember'; +import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; +// TODO: Mirage is not type-friendly / assigns "server" as a global. Try to work around this shortcoming. + +module('Acceptance | job status panel', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function () { + server.create('node'); + }); + + test('Status panel lets you switch between Current and Historical', async function (assert) { + assert.expect(5); + let job = server.create('job', { + status: 'running', + datacenters: ['*'], + type: 'service', + createAllocations: true, + }); + + await visit(`/jobs/${job.id}`); + assert.dom('.job-status-panel').exists(); + await a11yAudit(assert); + await percySnapshot(assert); + + assert + .dom('[data-test-status-mode="current"]') + .exists('Current mode by default'); + + await click('[data-test-status-mode-current]'); + + assert + .dom('[data-test-status-mode="current"]') + .exists('Clicking active mode makes no change'); + + await click('[data-test-status-mode-historical]'); + + assert + .dom('[data-test-status-mode="historical"]') + .exists('Lets you switch to historical mode'); + }); + + test('Status panel observes query parameters for current/historical', async function (assert) { + assert.expect(2); + let job = server.create('job', { + status: 'running', + datacenters: ['*'], + type: 'service', + createAllocations: true, + }); + + await visit(`/jobs/${job.id}?statusMode=historical`); + assert.dom('.job-status-panel').exists(); + + assert + .dom('[data-test-status-mode="historical"]') + .exists('Historical mode when rendered with queryParams'); + }); + + test('Status Panel shows accurate number and types of ungrouped allocation blocks', async function (assert) { + assert.expect(7); + + faker.seed(1); + + let groupTaskCount = 10; + + let job = server.create('job', { + status: 'running', + datacenters: ['*'], + type: 'service', + resourceSpec: ['M: 256, C: 500'], // a single group + createAllocations: true, + allocStatusDistribution: { + running: 1, + failed: 0, + unknown: 0, + lost: 0, + }, + groupTaskCount, + shallow: true, + }); + + await visit(`/jobs/${job.id}`); + assert.dom('.job-status-panel').exists(); + + let jobAllocCount = server.db.allocations.where({ + jobId: job.id, + }).length; + + assert.equal( + jobAllocCount, + groupTaskCount * job.taskGroups.length, + 'Correect number of allocs generated (metatest)' + ); + assert + .dom('.ungrouped-allocs .represented-allocation.running') + .exists( + { count: jobAllocCount }, + `All ${jobAllocCount} allocations are represented in the status panel` + ); + + groupTaskCount = 20; + + job = server.create('job', { + status: 'running', + datacenters: ['*'], + type: 'service', + resourceSpec: ['M: 256, C: 500'], // a single group + createAllocations: true, + allocStatusDistribution: { + running: 0.5, + failed: 0.5, + unknown: 0, + lost: 0, + }, + groupTaskCount, + noActiveDeployment: true, + shallow: true, + }); + + await visit(`/jobs/${job.id}`); + assert.dom('.job-status-panel').exists(); + + let runningAllocCount = server.db.allocations.where({ + jobId: job.id, + clientStatus: 'running', + }).length; + + let failedAllocCount = server.db.allocations.where({ + jobId: job.id, + clientStatus: 'failed', + }).length; + + assert.equal( + runningAllocCount + failedAllocCount, + groupTaskCount * job.taskGroups.length, + 'Correect number of allocs generated (metatest)' + ); + assert + .dom('.ungrouped-allocs .represented-allocation.running') + .exists( + { count: runningAllocCount }, + `All ${runningAllocCount} running allocations are represented in the status panel` + ); + assert + .dom('.ungrouped-allocs .represented-allocation.failed') + .exists( + { count: failedAllocCount }, + `All ${failedAllocCount} failed allocations are represented in the status panel` + ); + await percySnapshot(assert); + }); + + test('Status Panel groups allocations when they get past a threshold', async function (assert) { + assert.expect(6); + + faker.seed(1); + + let groupTaskCount = 20; + + let job = server.create('job', { + status: 'running', + datacenters: ['*'], + type: 'service', + resourceSpec: ['M: 256, C: 500'], // a single group + createAllocations: true, + allocStatusDistribution: { + running: 1, + failed: 0, + unknown: 0, + lost: 0, + }, + groupTaskCount, + shallow: true, + }); + + await visit(`/jobs/${job.id}`); + assert.dom('.job-status-panel').exists(); + + let jobAllocCount = server.db.allocations.where({ + jobId: job.id, + }).length; + + assert + .dom('.ungrouped-allocs .represented-allocation.running') + .exists( + { count: jobAllocCount }, + `All ${jobAllocCount} allocations are represented in the status panel, ungrouped` + ); + + groupTaskCount = 40; + + job = server.create('job', { + status: 'running', + datacenters: ['*'], + type: 'service', + resourceSpec: ['M: 256, C: 500'], // a single group + createAllocations: true, + allocStatusDistribution: { + running: 1, + failed: 0, + unknown: 0, + lost: 0, + }, + groupTaskCount, + shallow: true, + }); + + await visit(`/jobs/${job.id}`); + assert.dom('.job-status-panel').exists(); + + jobAllocCount = server.db.allocations.where({ + jobId: job.id, + }).length; + + // At standard test resolution, 40 allocations will attempt to display 20 ungrouped, and 20 grouped. + let desiredUngroupedAllocCount = 20; + assert + .dom('.ungrouped-allocs .represented-allocation.running') + .exists( + { count: desiredUngroupedAllocCount }, + `${desiredUngroupedAllocCount} allocations are represented ungrouped` + ); + + assert + .dom('.represented-allocation.rest') + .exists('Allocations are numerous enough that a summary block exists'); + assert + .dom('.represented-allocation.rest') + .hasText( + `+${groupTaskCount - desiredUngroupedAllocCount}`, + 'Summary block has the correct number of grouped allocs' + ); + + await percySnapshot(assert); + }); + + test('Status Panel groups allocations when they get past a threshold, multiple statuses', async function (assert) { + let groupTaskCount = 50; + + let job = server.create('job', { + status: 'running', + datacenters: ['*'], + type: 'service', + resourceSpec: ['M: 256, C: 500'], // a single group + createAllocations: true, + allocStatusDistribution: { + running: 0.5, + failed: 0.3, + pending: 0.1, + lost: 0.1, + }, + groupTaskCount, + shallow: true, + }); + + await visit(`/jobs/${job.id}`); + assert.dom('.job-status-panel').exists(); + + // With 50 allocs split across 4 statuses distributed as above, we can expect 25 running, 16 failed, 6 pending, and 4 remaining. + // At standard test resolution, each status will be ungrouped/grouped as follows: + // 25 running: 9 ungrouped, 17 grouped + // 15 failed: 5 ungrouped, 10 grouped + // 5 pending: 0 ungrouped, 5 grouped + // 5 lost: 0 ungrouped, 5 grouped. Represented as "Unplaced" + + assert + .dom('.ungrouped-allocs .represented-allocation.running') + .exists({ count: 9 }, '9 running allocations are represented ungrouped'); + assert + .dom('.represented-allocation.rest.running') + .exists( + 'Running allocations are numerous enough that a summary block exists' + ); + assert + .dom('.represented-allocation.rest.running') + .hasText( + '+16', + 'Summary block has the correct number of grouped running allocs' + ); + + assert + .dom('.ungrouped-allocs .represented-allocation.failed') + .exists({ count: 5 }, '5 failed allocations are represented ungrouped'); + assert + .dom('.represented-allocation.rest.failed') + .exists( + 'Failed allocations are numerous enough that a summary block exists' + ); + assert + .dom('.represented-allocation.rest.failed') + .hasText( + '+10', + 'Summary block has the correct number of grouped failed allocs' + ); + + assert + .dom('.ungrouped-allocs .represented-allocation.pending') + .exists({ count: 0 }, '0 pending allocations are represented ungrouped'); + assert + .dom('.represented-allocation.rest.pending') + .exists( + 'pending allocations are numerous enough that a summary block exists' + ); + assert + .dom('.represented-allocation.rest.pending') + .hasText( + '5', + 'Summary block has the correct number of grouped pending allocs' + ); + + assert + .dom('.ungrouped-allocs .represented-allocation.unplaced') + .exists({ count: 0 }, '0 unplaced allocations are represented ungrouped'); + assert + .dom('.represented-allocation.rest.unplaced') + .exists( + 'Unplaced allocations are numerous enough that a summary block exists' + ); + assert + .dom('.represented-allocation.rest.unplaced') + .hasText( + '5', + 'Summary block has the correct number of grouped unplaced allocs' + ); + await percySnapshot( + 'Status Panel groups allocations when they get past a threshold, multiple statuses (full width)' + ); + + // Simulate a window resize event; will recompute how many of each ought to be grouped. + + // At 1100px, only running and failed allocations have some ungrouped allocs + find('.page-body').style.width = '1100px'; + await triggerEvent(window, 'resize'); + + await percySnapshot( + 'Status Panel groups allocations when they get past a threshold, multiple statuses (1100px)' + ); + + assert + .dom('.ungrouped-allocs .represented-allocation.running') + .exists({ count: 7 }, '7 running allocations are represented ungrouped'); + assert + .dom('.represented-allocation.rest.running') + .exists( + 'Running allocations are numerous enough that a summary block exists' + ); + assert + .dom('.represented-allocation.rest.running') + .hasText( + '+18', + 'Summary block has the correct number of grouped running allocs' + ); + + assert + .dom('.ungrouped-allocs .represented-allocation.failed') + .exists({ count: 4 }, '4 failed allocations are represented ungrouped'); + assert + .dom('.represented-allocation.rest.failed') + .exists( + 'Failed allocations are numerous enough that a summary block exists' + ); + assert + .dom('.represented-allocation.rest.failed') + .hasText( + '+11', + 'Summary block has the correct number of grouped failed allocs' + ); + + // At 500px, only running allocations have some ungrouped allocs. The rest are all fully grouped. + find('.page-body').style.width = '800px'; + await triggerEvent(window, 'resize'); + + await percySnapshot( + 'Status Panel groups allocations when they get past a threshold, multiple statuses (500px)' + ); + + assert + .dom('.ungrouped-allocs .represented-allocation.running') + .exists({ count: 4 }, '4 running allocations are represented ungrouped'); + assert + .dom('.represented-allocation.rest.running') + .exists( + 'Running allocations are numerous enough that a summary block exists' + ); + assert + .dom('.represented-allocation.rest.running') + .hasText( + '+21', + 'Summary block has the correct number of grouped running allocs' + ); + + assert + .dom('.ungrouped-allocs .represented-allocation.failed') + .doesNotExist('no failed allocations are represented ungrouped'); + assert + .dom('.represented-allocation.rest.failed') + .exists( + 'Failed allocations are numerous enough that a summary block exists' + ); + assert + .dom('.represented-allocation.rest.failed') + .hasText( + '15', + 'Summary block has the correct number of grouped failed allocs' + ); + }); + + module('deployment history', function () { + test('Deployment history can be searched', async function (assert) { + faker.seed(1); + + let groupTaskCount = 10; + + let job = server.create('job', { + status: 'running', + datacenters: ['*'], + type: 'service', + resourceSpec: ['M: 256, C: 500'], // a single group + createAllocations: true, + allocStatusDistribution: { + running: 1, + failed: 0, + unknown: 0, + lost: 0, + }, + groupTaskCount, + shallow: true, + activeDeployment: true, + version: 0, + }); + + let state = server.create('task-state'); + state.events = server.schema.taskEvents.where({ taskStateId: state.id }); + + server.schema.allocations.where({ jobId: job.id }).update({ + taskStateIds: [state.id], + jobVersion: 0, + }); + + await visit(`/jobs/${job.id}`); + assert.dom('.job-status-panel').exists(); + + const serverEvents = server.schema.taskEvents.where({ + taskStateId: state.id, + }); + const shownEvents = findAll('.timeline-object'); + const jobAllocations = server.db.allocations.where({ jobId: job.id }); + assert.equal( + shownEvents.length, + serverEvents.length * jobAllocations.length, + 'All events are shown' + ); + + await fillIn( + '[data-test-history-search] input', + serverEvents.models[0].message + ); + assert.equal( + findAll('.timeline-object').length, + jobAllocations.length, + 'Only events matching the search are shown' + ); + + await fillIn('[data-test-history-search] input', 'foo bar baz'); + assert + .dom('[data-test-history-search-no-match]') + .exists('No match message is shown'); + }); + }); +}); diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index 9c5e2d201..234f5816e 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -46,7 +46,7 @@ module('Acceptance | jobs list', function (hooks) { test('/jobs should list the first page of jobs sorted by modify index', async function (assert) { faker.seed(1); const jobsCount = JobsList.pageSize + 1; - server.createList('job', jobsCount, { createAllocations: false }); + server.createList('job', jobsCount, { createAllocations: true }); await JobsList.visit(); diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index f8b520fcc..f4272b098 100644 --- a/ui/tests/acceptance/task-detail-test.js +++ b/ui/tests/acceptance/task-detail-test.js @@ -222,7 +222,7 @@ module('Acceptance | task detail', function (hooks) { 'Event timestamp' ); assert.equal(recentEvent.type, event.type, 'Event type'); - assert.equal(recentEvent.message, event.displayMessage, 'Event message'); + assert.equal(recentEvent.message, event.message, 'Event message'); }); test('when the allocation is not found, the application errors', async function (assert) { diff --git a/ui/tests/helpers/module-for-job.js b/ui/tests/helpers/module-for-job.js index 31dd801ee..3a4677da7 100644 --- a/ui/tests/helpers/module-for-job.js +++ b/ui/tests/helpers/module-for-job.js @@ -10,6 +10,7 @@ import { currentRouteName, currentURL, visit, + find, } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; @@ -17,6 +18,12 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import JobDetail from 'nomad-ui/tests/pages/jobs/detail'; import setPolicy from 'nomad-ui/tests/utils/set-policy'; +const jobTypesWithStatusPanel = ['service']; + +async function switchToHistorical() { + await JobDetail.statusModes.historical.click(); +} + // moduleFor is an old Ember-QUnit API that is deprected https://guides.emberjs.com/v1.10.0/testing/unit-test-helpers/ // this is a misnomer in our context, because we're not using this API, however, the linter does not understand this // the linter warning will go away if we rename this factory function to generateJobDetailsTests @@ -120,6 +127,9 @@ export default function moduleForJob( if (context === 'allocations') { test('allocations for the job are shown in the overview', async function (assert) { + if (jobTypesWithStatusPanel.includes(job.type)) { + await switchToHistorical(job); + } assert.ok( JobDetail.allocationsSummary.isPresent, 'Allocations are shown in the summary section' @@ -157,9 +167,11 @@ export default function moduleForJob( }); test('clicking legend item navigates to a pre-filtered allocations table', async function (assert) { - const legendItem = - JobDetail.allocationsSummary.legend.clickableItems[1]; - const status = legendItem.label; + if (jobTypesWithStatusPanel.includes(job.type)) { + await switchToHistorical(job); + } + const legendItem = find('.legend li.is-clickable'); + const status = legendItem.getAttribute('data-test-legend-label'); await legendItem.click(); const encodedStatus = encodeURIComponent(JSON.stringify([status])); @@ -176,7 +188,10 @@ export default function moduleForJob( }); test('clicking in a slice takes you to a pre-filtered allocations table', async function (assert) { - const slice = JobDetail.allocationsSummary.slices[1]; + if (jobTypesWithStatusPanel.includes(job.type)) { + await switchToHistorical(job); + } + const slice = JobDetail.allocationsSummary.slices[0]; const status = slice.label; await slice.click(); diff --git a/ui/tests/integration/components/job-editor-test.js b/ui/tests/integration/components/job-editor-test.js index 0fba55a20..a48509716 100644 --- a/ui/tests/integration/components/job-editor-test.js +++ b/ui/tests/integration/components/job-editor-test.js @@ -132,7 +132,6 @@ module('Integration | Component | job-editor', function (hooks) { await renderNewJob(this, job); await planJob(spec); - console.log('wait'); const requests = this.server.pretender.handledRequests.mapBy('url'); assert.notOk( requests.includes('/v1/jobs/parse'), diff --git a/ui/tests/integration/components/job-page/service-test.js b/ui/tests/integration/components/job-page/service-test.js index bbff21f8b..3f2409725 100644 --- a/ui/tests/integration/components/job-page/service-test.js +++ b/ui/tests/integration/components/job-page/service-test.js @@ -44,7 +44,10 @@ module('Integration | Component | job-page/service', function (hooks) { @sortProperty={{sortProperty}} @sortDescending={{sortDescending}} @currentPage={{currentPage}} - @gotoJob={{gotoJob}} /> + @gotoJob={{gotoJob}} + @statusMode={{statusMode}} + @setStatusMode={{setStatusMode}} + /> `; const commonProperties = (job) => ({ @@ -53,6 +56,8 @@ module('Integration | Component | job-page/service', function (hooks) { sortDescending: true, currentPage: 1, gotoJob() {}, + statusMode: 'current', + setStatusMode() {}, }); const makeMirageJob = (server, props = {}) => @@ -272,7 +277,11 @@ module('Integration | Component | job-page/service', function (hooks) { 'The error message mentions ACLs' ); - await componentA11yAudit(this.element, assert); + await componentA11yAudit( + this.element, + assert, + 'scrollable-region-focusable' + ); //keyframe animation fades from opacity 0 await click('[data-test-job-error-close]'); @@ -335,7 +344,11 @@ module('Integration | Component | job-page/service', function (hooks) { 'The error message mentions ACLs' ); - await componentA11yAudit(this.element, assert); + await componentA11yAudit( + this.element, + assert, + 'scrollable-region-focusable' + ); //keyframe animation fades from opacity 0 await click('[data-test-job-error-close]'); diff --git a/ui/tests/integration/components/job-status-panel-test.js b/ui/tests/integration/components/job-status-panel-test.js new file mode 100644 index 000000000..9280e7417 --- /dev/null +++ b/ui/tests/integration/components/job-status-panel-test.js @@ -0,0 +1,408 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { find, render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import { startMirage } from 'nomad-ui/initializers/ember-cli-mirage'; +import { initialize as fragmentSerializerInitializer } from 'nomad-ui/initializers/fragment-serializer'; +import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; +import percySnapshot from '@percy/ember'; + +module( + 'Integration | Component | job status panel | active deployment', + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + fragmentSerializerInitializer(this.owner); + window.localStorage.clear(); + this.store = this.owner.lookup('service:store'); + this.server = startMirage(); + this.server.create('namespace'); + }); + + hooks.afterEach(function () { + this.server.shutdown(); + window.localStorage.clear(); + }); + + test('there is no latest deployment section when the job has no deployments', async function (assert) { + this.server.create('job', { + type: 'service', + noDeployments: true, + createAllocations: false, + }); + + await this.store.findAll('job'); + + this.set('job', this.store.peekAll('job').get('firstObject')); + await render(hbs` + ) + `); + + assert.notOk(find('.active-deployment'), 'No active deployment'); + }); + + test('the latest deployment section shows up for the currently running deployment: Ungrouped Allocations (small cluster)', async function (assert) { + assert.expect(25); + + this.server.create('node'); + + const NUMBER_OF_GROUPS = 2; + const ALLOCS_PER_GROUP = 10; + const allocStatusDistribution = { + running: 0.5, + failed: 0.2, + unknown: 0.1, + lost: 0, + complete: 0.1, + pending: 0.1, + }; + + const job = await this.server.create('job', { + type: 'service', + createAllocations: true, + noDeployments: true, // manually created below + activeDeployment: true, + groupTaskCount: ALLOCS_PER_GROUP, + shallow: true, + resourceSpec: Array(NUMBER_OF_GROUPS).fill(['M: 257, C: 500']), // length of this array determines number of groups + allocStatusDistribution, + }); + + const jobRecord = await this.store.find( + 'job', + JSON.stringify([job.id, 'default']) + ); + await this.server.create('deployment', false, 'active', { + jobId: job.id, + groupDesiredTotal: ALLOCS_PER_GROUP, + versionNumber: 1, + status: 'failed', + }); + + const OLD_ALLOCATIONS_TO_SHOW = 25; + const OLD_ALLOCATIONS_TO_COMPLETE = 5; + + this.server.createList('allocation', OLD_ALLOCATIONS_TO_SHOW, { + jobId: job.id, + jobVersion: 0, + clientStatus: 'running', + }); + + this.set('job', jobRecord); + await this.get('job.allocations'); + + await render(hbs` + + `); + + // Initially no active deployment + assert.notOk( + find('.active-deployment'), + 'Does not show an active deployment when latest is failed' + ); + + const deployment = await this.get('job.latestDeployment'); + + await this.set('job.latestDeployment.status', 'running'); + + assert.ok( + find('.active-deployment'), + 'Shows an active deployment if latest status is Running' + ); + + assert.ok( + find('.active-deployment').classList.contains('is-info'), + 'Running deployment gets the is-info class' + ); + + // Half the shown allocations are running, 1 is pending, 1 is failed; none are canaries or healthy. + // The rest (lost, unknown, etc.) all show up as "Unplaced" + assert + .dom('.new-allocations .allocation-status-row .represented-allocation') + .exists( + { count: NUMBER_OF_GROUPS * ALLOCS_PER_GROUP }, + 'All allocations are shown (ungrouped)' + ); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.running' + ) + .exists( + { + count: + NUMBER_OF_GROUPS * + ALLOCS_PER_GROUP * + allocStatusDistribution.running, + }, + 'Correct number of running allocations are shown' + ); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.running.canary' + ) + .exists({ count: 0 }, 'No running canaries shown by default'); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.running.healthy' + ) + .exists({ count: 0 }, 'No running healthy shown by default'); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.failed' + ) + .exists( + { + count: + NUMBER_OF_GROUPS * + ALLOCS_PER_GROUP * + allocStatusDistribution.failed, + }, + 'Correct number of failed allocations are shown' + ); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.failed.canary' + ) + .exists({ count: 0 }, 'No failed canaries shown by default'); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.pending' + ) + .exists( + { + count: + NUMBER_OF_GROUPS * + ALLOCS_PER_GROUP * + allocStatusDistribution.pending, + }, + 'Correct number of pending allocations are shown' + ); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.pending.canary' + ) + .exists({ count: 0 }, 'No pending canaries shown by default'); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.unplaced' + ) + .exists( + { + count: + NUMBER_OF_GROUPS * + ALLOCS_PER_GROUP * + (allocStatusDistribution.lost + + allocStatusDistribution.unknown + + allocStatusDistribution.complete), + }, + 'Correct number of unplaced allocations are shown' + ); + + assert.equal( + find('[data-test-new-allocation-tally]').textContent.trim(), + `New allocations: ${ + this.job.allocations.filter( + (a) => + a.clientStatus === 'running' && + a.deploymentStatus?.Healthy === true + ).length + }/${deployment.get('desiredTotal')} running and healthy`, + 'Summary text shows accurate numbers when 0 are running/healthy' + ); + + let NUMBER_OF_RUNNING_CANARIES = 2; + let NUMBER_OF_RUNNING_HEALTHY = 5; + let NUMBER_OF_FAILED_CANARIES = 1; + let NUMBER_OF_PENDING_CANARIES = 1; + + // Set some allocs to canary, and to healthy + this.get('job.allocations') + .filter((a) => a.clientStatus === 'running') + .slice(0, NUMBER_OF_RUNNING_CANARIES) + .forEach((alloc) => + alloc.set('deploymentStatus', { + Canary: true, + Healthy: alloc.deploymentStatus?.Healthy, + }) + ); + this.get('job.allocations') + .filter((a) => a.clientStatus === 'running') + .slice(0, NUMBER_OF_RUNNING_HEALTHY) + .forEach((alloc) => + alloc.set('deploymentStatus', { + Canary: alloc.deploymentStatus?.Canary, + Healthy: true, + }) + ); + this.get('job.allocations') + .filter((a) => a.clientStatus === 'failed') + .slice(0, NUMBER_OF_FAILED_CANARIES) + .forEach((alloc) => + alloc.set('deploymentStatus', { + Canary: true, + Healthy: alloc.deploymentStatus?.Healthy, + }) + ); + this.get('job.allocations') + .filter((a) => a.clientStatus === 'pending') + .slice(0, NUMBER_OF_PENDING_CANARIES) + .forEach((alloc) => + alloc.set('deploymentStatus', { + Canary: true, + Healthy: alloc.deploymentStatus?.Healthy, + }) + ); + + await render(hbs` + + `); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.running.canary' + ) + .exists( + { count: NUMBER_OF_RUNNING_CANARIES }, + 'Running Canaries shown when deployment info dictates' + ); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.running.healthy' + ) + .exists( + { count: NUMBER_OF_RUNNING_HEALTHY }, + 'Running Healthy allocs shown when deployment info dictates' + ); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.failed.canary' + ) + .exists( + { count: NUMBER_OF_FAILED_CANARIES }, + 'Failed Canaries shown when deployment info dictates' + ); + assert + .dom( + '.new-allocations .allocation-status-row .represented-allocation.pending.canary' + ) + .exists( + { count: NUMBER_OF_PENDING_CANARIES }, + 'Pending Canaries shown when deployment info dictates' + ); + + assert.equal( + find('[data-test-new-allocation-tally]').textContent.trim(), + `New allocations: ${ + this.job.allocations.filter( + (a) => + a.clientStatus === 'running' && + a.deploymentStatus?.Healthy === true + ).length + }/${deployment.get('desiredTotal')} running and healthy`, + 'Summary text shows accurate numbers when some are running/healthy' + ); + + assert.equal( + find('[data-test-old-allocation-tally]').textContent.trim(), + `Previous allocations: ${ + this.job.allocations.filter( + (a) => + (a.clientStatus === 'running' || a.clientStatus === 'complete') && + a.jobVersion !== deployment.versionNumber + ).length + } running`, + 'Old Alloc Summary text shows accurate numbers' + ); + + assert.equal( + find('[data-test-previous-allocations-legend]') + .textContent.trim() + .replace(/\s\s+/g, ' '), + '25 Running 0 Complete' + ); + + await percySnapshot( + "Job Status Panel: 'New' and 'Previous' allocations, initial deploying state" + ); + + // Try setting a few of the old allocs to complete and make sure number ticks down + await Promise.all( + this.get('job.allocations') + .filter( + (a) => + a.clientStatus === 'running' && + a.jobVersion !== deployment.versionNumber + ) + .slice(0, OLD_ALLOCATIONS_TO_COMPLETE) + .map(async (a) => await a.set('clientStatus', 'complete')) + ); + + assert + .dom( + '.previous-allocations .allocation-status-row .represented-allocation' + ) + .exists( + { count: OLD_ALLOCATIONS_TO_SHOW }, + 'All old allocations are shown' + ); + assert + .dom( + '.previous-allocations .allocation-status-row .represented-allocation.complete' + ) + .exists( + { count: OLD_ALLOCATIONS_TO_COMPLETE }, + 'Correct number of old allocations are in completed state' + ); + + assert.equal( + find('[data-test-old-allocation-tally]').textContent.trim(), + `Previous allocations: ${ + this.job.allocations.filter( + (a) => + (a.clientStatus === 'running' || a.clientStatus === 'complete') && + a.jobVersion !== deployment.versionNumber + ).length - OLD_ALLOCATIONS_TO_COMPLETE + } running`, + 'Old Alloc Summary text shows accurate numbers after some are marked complete' + ); + + assert.equal( + find('[data-test-previous-allocations-legend]') + .textContent.trim() + .replace(/\s\s+/g, ' '), + '20 Running 5 Complete' + ); + + await percySnapshot( + "Job Status Panel: 'New' and 'Previous' allocations, some old marked complete" + ); + + await componentA11yAudit( + this.element, + assert, + 'scrollable-region-focusable' + ); //keyframe animation fades from opacity 0 + }); + + test('when there is no running deployment, the latest deployment section shows up for the last deployment', async function (assert) { + this.server.create('job', { + type: 'service', + createAllocations: false, + noActiveDeployment: true, + }); + + await this.store.findAll('job'); + + this.set('job', this.store.peekAll('job').get('firstObject')); + await render(hbs` + + `); + + assert.notOk(find('.active-deployment'), 'No active deployment'); + assert.ok( + find('.running-allocs-title'), + 'Steady-state mode shown instead' + ); + }); + } +); diff --git a/ui/tests/pages/jobs/detail.js b/ui/tests/pages/jobs/detail.js index 692e326be..c7e41b411 100644 --- a/ui/tests/pages/jobs/detail.js +++ b/ui/tests/pages/jobs/detail.js @@ -82,6 +82,17 @@ export default create({ return this.packStats.toArray().findBy('id', id); }, + statusModes: { + current: { + scope: '[data-test-status-mode-current]', + click: clickable(), + }, + historical: { + scope: '[data-test-status-mode-historical]', + click: clickable(), + }, + }, + jobClientStatusSummary: { scope: '[data-test-job-client-summary]', statusBar: jobClientStatusBar('[data-test-job-client-status-bar]'), @@ -93,10 +104,10 @@ export default create({ }, }, childrenSummary: jobClientStatusBar( - '[data-test-job-summary] [data-test-children-status-bar]' + '[data-test-children-status-bar]:not(.is-narrow)' ), allocationsSummary: jobClientStatusBar( - '[data-test-job-summary] [data-test-allocation-status-bar]' + '[data-test-allocation-status-bar]:not(.is-narrow)' ), ...taskGroups(), ...allocations(),