diff --git a/ui/app/components/conditional-link-to.hbs b/ui/app/components/conditional-link-to.hbs new file mode 100644 index 00000000000..00766532da5 --- /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 00000000000..7dd1d3b31b0 --- /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-status/allocation-status-block.hbs b/ui/app/components/job-status/allocation-status-block.hbs index c53de338436..5ff785fc378 100644 --- a/ui/app/components/job-status/allocation-status-block.hbs +++ b/ui/app/components/job-status/allocation-status-block.hbs @@ -1,17 +1,58 @@
{{#if this.countToShow}}
- {{#each (range 0 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}} - + + + {{#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-row.hbs b/ui/app/components/job-status/allocation-status-row.hbs index 96476abe686..2b04ecb56b3 100644 --- a/ui/app/components/job-status/allocation-status-row.hbs +++ b/ui/app/components/job-status/allocation-status-row.hbs @@ -1,22 +1,64 @@ -{{#if this.showSummaries}} -
- {{#each-in @allocBlocks as |status allocs|}} - {{#if (gt allocs.length 0)}} - - {{/if}} - {{/each-in}} -
-{{else}} -
- {{#each-in @allocBlocks as |status allocs|}} - {{#if (gt allocs.length 0)}} - {{#each (range 0 allocs.length)}} - - {{/each}} - {{/if}} - {{/each-in}} -
-{{/if}} \ No newline at end of file +
+ {{#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 index 71bc77ec051..a23429347e1 100644 --- a/ui/app/components/job-status/allocation-status-row.js +++ b/ui/app/components/job-status/allocation-status-row.js @@ -1,21 +1,31 @@ +// @ts-check import Component from '@glimmer/component'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; -const UNGROUPED_ALLOCS_THRESHOLD = 50; +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).reduce( - (m, n) => m + n.length, - 0 - ); + 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 > UNGROUPED_ALLOCS_THRESHOLD; + return ( + this.allocBlockSlots * (ALLOC_BLOCK_WIDTH + ALLOC_BLOCK_GAP) - + ALLOC_BLOCK_GAP > + this.width + ); } calcPerc(count) { 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 00000000000..ec0efacc3d6 --- /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 00000000000..61d76b2bab7 --- /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 index e932f50eecb..1bd4fc08ce4 100644 --- a/ui/app/components/job-status/panel.hbs +++ b/ui/app/components/job-status/panel.hbs @@ -1,53 +1,5 @@ -
-
-

Status

-
- - -
-
-
- {{#if (eq @statusMode "current")}} -

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

- - - -
- - {{#each this.allocTypes as |type|}} - - - {{get (get this.allocBlocks type.label) "length"}} {{capitalize type.label}} - - {{/each}} - - -
-

Versions

-
    - {{#each-in this.versions as |version allocs|}} -
  • - - -
  • - {{/each-in}} -
-
-
- {{/if}} - {{#if (eq @statusMode "historical")}} - - {{/if}} -
-
\ No newline at end of file +{{#if this.isActivelyDeploying}} + +{{else}} + +{{/if}} diff --git a/ui/app/components/job-status/panel.js b/ui/app/components/job-status/panel.js index 41c2ce7e799..e9f05dd662a 100644 --- a/ui/app/components/job-status/panel.js +++ b/ui/app/components/job-status/panel.js @@ -2,74 +2,7 @@ import Component from '@glimmer/component'; export default class JobStatusPanelComponent extends Component { - // 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, - property: `${type}Allocs`, - }; - }); - - 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] = Array( - Math.min(availableSlotsToFill, jobAllocsOfType.length) - ) - .fill() - .map((_, i) => { - return jobAllocsOfType[i]; - }); - availableSlotsToFill -= blocks[type.label].length; - } else { - blocks[type.label] = []; - } - return blocks; - }, {}); - if (availableSlotsToFill > 0) { - allocationsOfShowableType['unplaced'] = 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) - .flat() - .map((a) => (!isNaN(a?.jobVersion) ? `v${a.jobVersion}` : 'pending')) // "starting" allocs, and possibly others, do not yet have a jobVersion - .reduce( - (result, item) => ({ - ...result, - [item]: [...(result[item] || []), item], - }), - [] - ); + 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 00000000000..27213a0a269 --- /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 00000000000..966dcac7e28 --- /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 00000000000..b76221ef707 --- /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 00000000000..77a391d2a08 --- /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 00000000000..e9213dd6d29 --- /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 00000000000..79436a7db13 --- /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 ce5cb180af9..f3abbacf940 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/models/allocation.js b/ui/app/models/allocation.js index d949a46d8ba..0848308f401 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 f84615d925c..54c1490fb13 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/job-status-panel.scss b/ui/app/styles/components/job-status-panel.scss index c181bcac51a..fb939f73aa6 100644 --- a/ui/app/styles/components/job-status-panel.scss +++ b/ui/app/styles/components/job-status-panel.scss @@ -1,4 +1,80 @@ .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); @@ -35,9 +111,10 @@ display: grid; gap: 10px; grid-auto-flow: column; - grid-auto-columns: minmax(1px, 32px); + grid-auto-columns: 32px; + & > .represented-allocation { - width: auto; + width: 32px; } } @@ -66,14 +143,17 @@ } .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; - text-align: center; - display: grid; - align-content: center; 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; } @@ -87,14 +167,19 @@ 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-dark; + $complete: $nomad-green-pale; $failed: $danger; $lost: $dark; + // Client Statuses &.running { background: $running; } @@ -109,61 +194,249 @@ } &.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: transparent; - border: 2px solid $grey-lighter; + 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; } - & > .boxed-section-body > footer { + .legend-item { display: grid; gap: 0.5rem; - padding-top: 2rem; - grid-template-columns: 50% 50%; + grid-template-columns: auto 1fr; - legend { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.5rem; + &.faded { + opacity: 0.5; + } - .legend-item { - display: grid; - gap: 0.5rem; - grid-template-columns: auto 1fr; + .represented-allocation { + width: 20px; + height: 20px; + animation: none; + &:before, + &:after { + animation: none; + } + } + } - &.faded { - opacity: 0.5; - } + .history-and-params { + display: grid; + grid-template-columns: 70% auto; + gap: 1rem; + margin-top: 1rem; + } - .represented-allocation { - width: 20px; - height: 20px; - } + .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); + } - .versions { - h4 { - margin-bottom: 0.5rem; + &:nth-child(#{$i}) > div { + animation-name: historyItemShine; + animation-duration: 1s; + animation-fill-mode: both; + animation-delay: 0.1s + (0.05 * $i); + } } - & > ul { - display: grid; - grid-template-columns: repeat(auto-fit, 100px); + & > div { gap: 0.5rem; - & > li { - white-space: nowrap; + } + &.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 e816579146b..1583cf79ec5 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 663c3cd9334..5385071af79 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 c430afba76a..e2e750a2f4c 100644 --- a/ui/app/templates/components/job-page.hbs +++ b/ui/app/templates/components/job-page.hbs @@ -36,6 +36,7 @@ StatusPanel=(component "job-status/panel" job=@job + handleError=this.handleError ) ) diff --git a/ui/app/templates/components/job-page/service.hbs b/ui/app/templates/components/job-page/service.hbs index 083f34068fb..1420961e70a 100644 --- a/ui/app/templates/components/job-page/service.hbs +++ b/ui/app/templates/components/job-page/service.hbs @@ -11,6 +11,7 @@ + {{!-- 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 ab28b835e99..b6613ba3827 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/mirage/factories/deployment.js b/ui/mirage/factories/deployment.js index 6af73f149f2..6313858e9eb 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/task-event.js b/ui/mirage/factories/task-event.js index 4855c17fc09..34bd967efdc 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 c22013ab99c..ee064597f64 100644 --- a/ui/mirage/factories/task-group.js +++ b/ui/mirage/factories/task-group.js @@ -123,23 +123,29 @@ export default Factory.extend({ unknown: 0.25, lost: 0.1, }; - - Array(group.count) + + 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 rand = faker.random.number({ min: 1, max: 100 }) / 100; // emulate Math.random float precision, but observe Faker random seed - let clientStatus; - - Object.entries(statusProbabilities).some(([status, prob]) => { - if (rand < prob) { - clientStatus = status; - return true; - } - rand -= prob; - return false; - }); - + + while (allocationsByStatus[statusKeys[currentStatusIndex]] === 0) { + currentStatusIndex++; + } + + clientStatus = statusKeys[currentStatusIndex]; + allocationsByStatus[clientStatus]--; + const props = { jobId: group.job.id, namespace: group.job.namespace, @@ -152,8 +158,12 @@ export default Factory.extend({ ? faker.random.number({ min: 1, max: 5 }) : 0, clientStatus, + deploymentStatus: { + Canary: false, + Healthy: false, + }, }; - + if (group.withRescheduling) { server.create('allocation', 'rescheduled', props); } else { diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index d7f1018e2a2..c9d1140f3b3 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -91,6 +91,76 @@ function smallCluster(server) { 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 22858d715f2..0e277f30ffa 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 index e27b3f4fdca..653b061b04f 100644 --- a/ui/tests/acceptance/job-status-panel-test.js +++ b/ui/tests/acceptance/job-status-panel-test.js @@ -2,7 +2,14 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { click, visit, find, triggerEvent } from '@ember/test-helpers'; +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'; @@ -108,7 +115,7 @@ module('Acceptance | job status panel', function (hooks) { `All ${jobAllocCount} allocations are represented in the status panel` ); - groupTaskCount = 40; + groupTaskCount = 20; job = server.create('job', { status: 'running', @@ -123,6 +130,7 @@ module('Acceptance | job status panel', function (hooks) { lost: 0, }, groupTaskCount, + noActiveDeployment: true, shallow: true, }); @@ -154,7 +162,7 @@ module('Acceptance | job status panel', function (hooks) { .dom('.ungrouped-allocs .represented-allocation.failed') .exists( { count: failedAllocCount }, - `All ${failedAllocCount} running allocations are represented in the status panel` + `All ${failedAllocCount} failed allocations are represented in the status panel` ); await percySnapshot(assert); }); @@ -164,7 +172,7 @@ module('Acceptance | job status panel', function (hooks) { faker.seed(1); - let groupTaskCount = 50; + let groupTaskCount = 20; let job = server.create('job', { status: 'running', @@ -196,7 +204,7 @@ module('Acceptance | job status panel', function (hooks) { `All ${jobAllocCount} allocations are represented in the status panel, ungrouped` ); - groupTaskCount = 51; + groupTaskCount = 40; job = server.create('job', { status: 'running', @@ -221,9 +229,8 @@ module('Acceptance | job status panel', function (hooks) { jobId: job.id, }).length; - // At standard test resolution, 51 allocations will attempt to display 20 ungrouped, and 31 grouped. + // 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( @@ -245,8 +252,7 @@ module('Acceptance | job status panel', function (hooks) { }); test('Status Panel groups allocations when they get past a threshold, multiple statuses', async function (assert) { - faker.seed(3); - let groupTaskCount = 51; + let groupTaskCount = 50; let job = server.create('job', { status: 'running', @@ -267,12 +273,12 @@ module('Acceptance | job status panel', function (hooks) { await visit(`/jobs/${job.id}`); assert.dom('.job-status-panel').exists(); - // With 51 allocs split across 4 statuses distributed as above, we can expect 25 running, 16 failed, 6 pending, and 4 remaining. + // 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, 16 grouped - // 13 failed: 5 ungrouped, 11 grouped - // 9 pending: 0 ungrouped, 6 grouped - // 4 lost: 0 ungrouped, 4 grouped. Represented as "Unplaced" + // 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') @@ -291,7 +297,7 @@ module('Acceptance | job status panel', function (hooks) { assert .dom('.ungrouped-allocs .represented-allocation.failed') - .exists({ count: 4 }, '4 failed allocations are represented ungrouped'); + .exists({ count: 5 }, '5 failed allocations are represented ungrouped'); assert .dom('.represented-allocation.rest.failed') .exists( @@ -300,7 +306,7 @@ module('Acceptance | job status panel', function (hooks) { assert .dom('.represented-allocation.rest.failed') .hasText( - '+9', + '+10', 'Summary block has the correct number of grouped failed allocs' ); @@ -315,7 +321,7 @@ module('Acceptance | job status panel', function (hooks) { assert .dom('.represented-allocation.rest.pending') .hasText( - '9', + '5', 'Summary block has the correct number of grouped pending allocs' ); @@ -330,7 +336,7 @@ module('Acceptance | job status panel', function (hooks) { assert .dom('.represented-allocation.rest.unplaced') .hasText( - '4', + '5', 'Summary block has the correct number of grouped unplaced allocs' ); await percySnapshot( @@ -339,17 +345,17 @@ module('Acceptance | job status panel', function (hooks) { // Simulate a window resize event; will recompute how many of each ought to be grouped. - // At 1000px, only running allocations have some ungrouped allocs. The rest are all fully grouped. - find('.page-body').style.width = '1000px'; + // 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 (1000px)' + 'Status Panel groups allocations when they get past a threshold, multiple statuses (1100px)' ); assert .dom('.ungrouped-allocs .represented-allocation.running') - .exists({ count: 6 }, '6 running allocations are represented ungrouped'); + .exists({ count: 7 }, '7 running allocations are represented ungrouped'); assert .dom('.represented-allocation.rest.running') .exists( @@ -358,13 +364,13 @@ module('Acceptance | job status panel', function (hooks) { assert .dom('.represented-allocation.rest.running') .hasText( - '+19', + '+18', 'Summary block has the correct number of grouped running allocs' ); assert .dom('.ungrouped-allocs .represented-allocation.failed') - .doesNotExist('5 failed allocations are represented ungrouped'); + .exists({ count: 4 }, '4 failed allocations are represented ungrouped'); assert .dom('.represented-allocation.rest.failed') .exists( @@ -373,7 +379,7 @@ module('Acceptance | job status panel', function (hooks) { assert .dom('.represented-allocation.rest.failed') .hasText( - '13', + '+11', 'Summary block has the correct number of grouped failed allocs' ); @@ -387,7 +393,7 @@ module('Acceptance | job status panel', function (hooks) { assert .dom('.ungrouped-allocs .represented-allocation.running') - .doesNotExist('6 running allocations are represented ungrouped'); + .exists({ count: 4 }, '4 running allocations are represented ungrouped'); assert .dom('.represented-allocation.rest.running') .exists( @@ -396,8 +402,86 @@ module('Acceptance | job status panel', function (hooks) { assert .dom('.represented-allocation.rest.running') .hasText( - '25', + '+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/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index f8b520fcc11..f4272b09861 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/integration/components/job-page/service-test.js b/ui/tests/integration/components/job-page/service-test.js index bc0cbf64a0d..3f2409725f7 100644 --- a/ui/tests/integration/components/job-page/service-test.js +++ b/ui/tests/integration/components/job-page/service-test.js @@ -277,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]'); @@ -340,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 00000000000..9280e7417b9 --- /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' + ); + }); + } +);