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 @@
+
+
+
+ {{#each this.history as |deployment-log|}}
+ -
+
+ {{deployment-log.state.allocation.shortId}}
+ {{deployment-log.type}}: {{deployment-log.message}}
+
+ {{format-ts deployment-log.time}}
+
+
+
+ {{else}}
+ {{#if this.errorState}}
+ -
+
+ Error loading deployment history
+
+
+ {{else}}
+ {{#if this.deploymentAllocations.length}}
+ {{#if this.searchTerm}}
+ -
+
+ No events match {{this.searchTerm}}
+
+
+ {{else}}
+ -
+
+ No deployment events yet
+
+
+ {{/if}}
+ {{else}}
+ -
+
+ Loading deployment events
+
+
+ {{/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}}
+
+
+
+
+
+
+
+ {{/if}}
+
+
New allocations: {{this.newRunningHealthyAllocBlocks.length}}/{{this.totalAllocs}} running and healthy
+
+
+
+
+
+
+
+ {{!-- Legend by Status, then by Health, then by 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
+
+
+
+
+
+
+ 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|}}
+ -
+
+
+ {{/each}}
+
+
+{{else}}
+
+
+ {{#each chart.data as |datum index|}}
+ -
+ {{#if (and (gt datum.value 0) datum.legendLink)}}
+
+
+
+ {{else}}
+
+ {{/if}}
+
+ {{/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|}}
- -
-
-
- {{/each}}
-
-
- {{else}}
-
-
- {{#each chart.data as |datum index|}}
- -
- {{#if (and (gt datum.value 0) datum.legendLink)}}
-
-
-
- {{else}}
-
- {{/if}}
-
- {{/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(),