[ui, feature] Job Page Redesign (#16932)

* [ui] Service job status panel (#16134)

* it begins

* Hacky demo enabled

* Still very hacky but seems deece

* Floor of at least 3 must be shown

* Width from on-high

* Other statuses considered

* More sensible allocTypes listing

* Beginnings of a legend

* Total number of allocs running now maps over job.groups

* Lintfix

* base the number of slots to hold open on actual tallies, which should never exceed totalAllocs

* Versions get yer versions here

* Versions lookin like versions

* Mirage fixup

* Adds Remaining as an alloc chart status and adds historical status option

* Get tests passing again by making job status static for a sec

* Historical status panel click actions moved into their own component class

* job detail tests plz chill

* Testing if percy is fickle

* Hyper-specfic on summary distribution bar identifier

* Perhaps the 2nd allocSummary item no longer exists with the more accurate afterCreate data

* UI Test eschewing the page pattern

* Bones of a new acceptance test

* Track width changes explicitly with window-resize

* testlintfix

* Alloc counting tests

* Alloc grouping test

* Alloc grouping with complex resizing

* Refined the list of showable statuses

* PR feedback addressed

* renamed allocation-row to allocation-status-row

* [ui, job status] Make panel status mode a queryParam (#16345)

* queryParam changing

* Test for QP in panel

* Adding @tracked to legacy controller

* Move the job of switching to Historical out to larger context

* integration test mock passed func

* [ui] Service job deployment status panel (#16383)

* A very fast and loose deployment panel

* Removing Unknown status from the panel

* Set up oldAllocs list in constructor, rather than as a getter/tracked var

* Small amount of template cleanup

* Refactored latest-deployment new logic back into panel.js

* Revert now-unused latest-deployment component

* margin bottom when ungrouped also

* Basic integration tests for job deployment status panel

* Updates complete alloc colour to green for new visualizations only (#16618)

* Updates complete alloc colour to green for new visualizations only

* Pale green instead of dark green for viz in general

* [ui] Job Deployment Status: History and Update Props (#16518)

* Deployment history wooooooo

* Styled deployment history

* Update Params

* lintfix

* Types and groups for updateParams

* Live-updating history

* Harden with types, error states, and pending states

* Refactor updateParams to use trigger component

* [ui] Deployment History search (#16608)

* Functioning searchbox

* Some nice animations for history items

* History search test

* Fixing up some old mirage conventions

* some a11y rule override to account for scss keyframes

* Split panel into deploying and steady components

* HandleError passed from job index

* gridified panel elements

* TotalAllocs added to deploying.js

* Width perc to px

* [ui] Splitting deployment allocs by status, health, and canary status (#16766)

* Initial attempt with lots of scratchpad work

* Style mods per UI discussion

* Fix canary overflow bug

* Dont show canary or health for steady/prev-alloc blocks

* Steady state

* Thanks Julie

* Fixes steady-state versions

* Legen, wait for it...

* Test fixes now that we have a minimum block size

* PR prep

* Shimmer effect on pending and unplaced allocs (#16801)

* Shimmer effect on pending and unplaced

* Dont show animation in the legend

* [ui, deployments] Linking allocblocks and legends to allocation / allocations index routes (#16821)

* Conditional link-to component and basic linking to allocations and allocation routes

* Job versions filter added to allocations index page

* Steady state legends link

* Legend links

* Badge count links for versions

* Fix: faded class on steady-state legend items

* version link now wont show completed ones

* Fix a11y violations with link labels

* Combining some template conditional logic

* [ui, deployments] Conversions on long nanosecond update params (#16882)

* Conversions on long nanosecond nums

* Early return in updateParamGroups comp prop

* [ui, deployments] Mirage Actively Deploying Job and Deployment Integration Tests (#16888)

* Start of deployment alloc test scaffolding

* Bit of test cleanup and canary for ungrouped allocs

* Flakey but more robust integrations for deployment panel

* De-flake acceptance tests and add an actively deploying job to mirage

* Jitter-less alloc status distribution removes my bad math

* bugfix caused by summary.desiredTotal non-null

* More interesting mirage active deployment alloc breakdown

* Further tests for previous-allocs row

* Previous alloc legend tests

* Percy snapshots added to integration test

* changelog
This commit is contained in:
Phil Renaud 2023-04-24 22:45:39 -04:00 committed by GitHub
parent 2c63d34296
commit 7dbebe9a93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 2706 additions and 131 deletions

3
.changelog/16932.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: Job status and deployment redesign
```

View File

@ -0,0 +1,9 @@
{{#if @condition}}
<LinkTo @route={{@route}} @model={{@model}} @query={{this.query}} class={{@class}} aria-label={{@label}}>
{{yield}}
</LinkTo>
{{else}}
<span class={{@class}}>
{{yield}}
</span>
{{/if}}

View File

@ -0,0 +1,7 @@
import Component from '@glimmer/component';
export default class ConditionalLinkToComponent extends Component {
get query() {
return this.args.query || {};
}
}

View File

@ -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)]);
}
}

View File

@ -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;

View File

@ -0,0 +1,58 @@
<div
class="allocation-status-block {{unless this.countToShow "rest-only"}}"
style={{html-safe (concat "width: " @width "px")}}
>
{{#if this.countToShow}}
<div class="ungrouped-allocs">
{{#each (range 0 this.countToShow) as |i|}}
<ConditionalLinkTo
@condition={{not (eq @status "unplaced")}}
@route="allocations.allocation"
@model={{get @allocs i}}
@class="represented-allocation {{@status}} {{@health}} {{@canary}}"
@label="View allocation"
>
{{#if (and (eq @status "running") (not @steady))}}
{{#if (eq @canary "canary")}}
<span class="alloc-canary-indicator" />
{{/if}}
<span class="alloc-health-indicator">
{{#if (eq @health "healthy")}}
<FlightIcon @name="check" @color="white" />
{{else}}
<FlightIcon @name="running" @color="white" />
{{/if}}
</span>
{{/if}}
</ConditionalLinkTo>
{{/each}}
</div>
{{/if}}
{{#if this.remaining}}
<ConditionalLinkTo
@condition={{not (eq @status "unplaced")}}
@route="jobs.job.allocations"
@model={{@allocs.0.job}}
@query={{hash status=(concat '["' @status '"]') version=(concat '[' @allocs.0.jobVersion ']')}}
@class="represented-allocation rest {{@status}} {{@health}} {{@canary}}"
@label="View all {{@status}} allocations"
>
<span class="rest-count">{{#if this.countToShow}}+{{/if}}{{this.remaining}}</span>
{{#unless @steady}}
{{#if (eq @canary "canary")}}
<span class="alloc-canary-indicator" />
{{/if}}
{{#if (eq @status "running")}}
<span class="alloc-health-indicator">
{{#if (eq @health "healthy")}}
<FlightIcon @name="check" @color="#25ba81" />
{{else}}
<FlightIcon @name="running" @color="black" />
{{/if}}
</span>
{{/if}}
{{/unless}}
</ConditionalLinkTo>
{{/if}}
</div>

View File

@ -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;
}
}

View File

@ -0,0 +1,64 @@
<div class="allocation-status-row">
{{#if this.showSummaries}}
<div class="alloc-status-summaries"
{{did-insert this.captureElement}}
{{window-resize this.reflow}}
>
{{#each-in @allocBlocks as |status allocsByStatus|}}
{{#each-in allocsByStatus as |health allocsByHealth|}}
{{#each-in allocsByHealth as |canary allocsByCanary|}}
{{#if (gt allocsByCanary.length 0)}}
<JobStatus::AllocationStatusBlock
@status={{status}}
@health={{health}}
@canary={{canary}}
@steady={{@steady}}
@count={{allocsByCanary.length}}
@width={{compute (action this.calcPerc) allocsByCanary.length}}
@allocs={{allocsByCanary}} />
{{/if}}
{{/each-in}}
{{/each-in}}
{{/each-in}}
</div>
{{else}}
<div class="ungrouped-allocs"
{{did-insert this.captureElement}}
{{window-resize this.reflow}}
>
{{#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|}}
<ConditionalLinkTo
@condition={{not (eq status "unplaced")}}
@route="allocations.allocation"
@model={{get allocsByCanary i}}
@class="represented-allocation {{status}} {{health}} {{canary}}"
@label="View allocation"
>
{{#unless @steady}}
{{#if (eq canary "canary")}}
<span class="alloc-canary-indicator" />
{{/if}}
{{#if (eq status "running")}}
<span class="alloc-health-indicator">
{{#if (eq health "healthy")}}
<FlightIcon @name="check" @color="white" />
{{else}}
<FlightIcon @name="running" @color="white" />
{{/if}}
</span>
{{/if}}
{{/unless}}
</ConditionalLinkTo>
{{/each}}
{{/if}}
{{/each-in}}
{{/each-in}}
{{/each-in}}
</div>
{{/if}}
</div>

View File

@ -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;
}
}

View File

@ -0,0 +1,53 @@
<div class="deployment-history">
<header>
<h4 class="title is-5">Deployment History</h4>
<SearchBox
data-test-history-search
@searchTerm={{mut this.searchTerm}}
@placeholder="Search events..."
/>
</header>
<ol class="timeline">
{{#each this.history as |deployment-log|}}
<li class="timeline-object {{if (eq deployment-log.exitCode 1) "error"}}">
<div class="boxed-section-head is-light">
<LinkTo @route="allocations.allocation" @model={{deployment-log.state.allocation}} class="allocation-reference">{{deployment-log.state.allocation.shortId}}</LinkTo>
<span><strong>{{deployment-log.type}}:</strong> {{deployment-log.message}}</span>
<span class="pull-right">
{{format-ts deployment-log.time}}
</span>
</div>
</li>
{{else}}
{{#if this.errorState}}
<li class="timeline-object">
<div class="boxed-section-head is-light">
<span>Error loading deployment history</span>
</div>
</li>
{{else}}
{{#if this.deploymentAllocations.length}}
{{#if this.searchTerm}}
<li class="timeline-object" data-test-history-search-no-match>
<div class="boxed-section-head is-light">
<span>No events match {{this.searchTerm}}</span>
</div>
</li>
{{else}}
<li class="timeline-object">
<div class="boxed-section-head is-light">
<span>No deployment events yet</span>
</div>
</li>
{{/if}}
{{else}}
<li class="timeline-object">
<div class="boxed-section-head is-light">
<span>Loading deployment events</span>
</div>
</li>
{{/if}}
{{/if}}
{{/each}}
</ol>
</div>

View File

@ -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
}

View File

@ -0,0 +1,5 @@
{{#if this.isActivelyDeploying}}
<JobStatus::Panel::Deploying @job={{@job}} @handleError={{@handleError}} />
{{else}}
<JobStatus::Panel::Steady @job={{@job}} @statusMode={{@statusMode}} @setStatusMode={{@setStatusMode}} />
{{/if}}

View File

@ -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');
}
}

View File

@ -0,0 +1,113 @@
<div class="job-status-panel boxed-section active-deployment is-info" data-test-job-status-panel>
<div class="boxed-section-head">
<div class="boxed-section-row"
{{did-insert (action this.establishOldAllocBlockIDs)}}
>
Deployment Status
<span class="badge is-white bumper-left" data-test-active-deployment-stat="id">{{@job.latestDeployment.shortId}}</span>
<div class="pull-right">
{{#if @job.latestDeployment.isRunning}}
<TwoStepButton
data-test-fail
@classes={{hash
idleButton="is-danger"
confirmationMessage="inherit-color"
confirmButton="is-danger"}}
@idleText="Fail Deployment"
@cancelText="Cancel"
@confirmText="Yes, Fail Deployment"
@confirmationMessage="Are you sure?"
@inlineText={{true}}
@awaitingConfirmation={{this.fail.isRunning}}
@disabled={{this.fail.isRunning}}
@onConfirm={{perform this.fail}} />
{{/if}}
{{#if @job.latestDeployment.requiresPromotion}}
<button
data-test-promote-canary
type="button"
class="button is-warning is-small {{if this.promote.isRunning "is-loading"}}"
disabled={{this.promote.isRunning}}
onclick={{perform this.promote}}>Promote Canary</button>
{{/if}}
</div>
</div>
</div>
<div class="boxed-section-body">
<div class="deployment-allocations">
{{#if this.oldVersionAllocBlockIDs.length}}
<h4 class="title is-5" data-test-old-allocation-tally>Previous allocations: {{#if this.oldVersionAllocBlocks.running}}{{this.oldRunningHealthyAllocBlocks.length}} running{{/if}}</h4>
<div class="previous-allocations">
<JobStatus::AllocationStatusRow @allocBlocks={{this.oldVersionAllocBlocks}} @steady={{true}} />
</div>
<div class="legend-and-summary" data-test-previous-allocations-legend>
<legend>
<span class="legend-item {{if (eq (get this.oldRunningHealthyAllocBlocks "length") 0) "faded"}}">
<span class="represented-allocation running"></span>
{{get this.oldRunningHealthyAllocBlocks "length"}} Running
</span>
<span class="legend-item {{if (eq (get this.oldCompleteHealthyAllocBlocks "length") 0) "faded"}}">
<span class="represented-allocation complete"></span>
{{get this.oldCompleteHealthyAllocBlocks "length"}} Complete
</span>
</legend>
</div>
{{/if}}
<h4 class="title is-5" data-test-new-allocation-tally>New allocations: {{this.newRunningHealthyAllocBlocks.length}}/{{this.totalAllocs}} running and healthy</h4>
<div class="new-allocations">
<JobStatus::AllocationStatusRow @allocBlocks={{this.newVersionAllocBlocks}} />
</div>
</div>
<div class="legend-and-summary" data-test-new-allocations-legend>
{{!-- Legend by Status, then by Health, then by Canary --}}
<legend>
{{#each-in this.newAllocsByStatus as |status count|}}
<ConditionalLinkTo
@condition={{not (eq status "unplaced")}}
@route="jobs.job.allocations"
@model={{@job}}
@query={{hash status=(concat '["' status '"]') version=(concat '[' this.job.latestDeployment.versionNumber ']')}}
@class="legend-item {{if (eq count 0) "faded"}}"
@label="View {{status}} allocations"
>
<span class="represented-allocation {{status}}"></span>
{{count}} {{capitalize status}}
</ConditionalLinkTo>
{{/each-in}}
{{#each-in this.newAllocsByHealth as |health count|}}
<span class="legend-item {{if (eq count 0) "faded"}}">
<span class="represented-allocation legend-example">
<span class="alloc-health-indicator">
{{#if (eq health "healthy")}}
<FlightIcon @name="check" @color="#25ba81" />
{{else}}
<FlightIcon @name="running" @color="black" class="not-animated" />
{{/if}}
</span>
</span>
{{count}} {{capitalize health}}
</span>
{{/each-in}}
<span class="legend-item {{if (eq this.newAllocsByCanary.canary 0) "faded"}}">
<span class="represented-allocation legend-example canary">
<span class="alloc-canary-indicator" />
</span>
{{this.newAllocsByCanary.canary}} Canary
</span>
</legend>
</div>
<div class="history-and-params">
<JobStatus::DeploymentHistory @deployment={{@job.latestDeployment}} />
<JobStatus::UpdateParams @job={{@job}} />
</div>
</div>
</div>

View File

@ -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);
}
}

View File

@ -0,0 +1,61 @@
<div class="job-status-panel boxed-section steady-state {{if (eq @statusMode "historical") "historical-state" "current-state"}}" data-test-job-status-panel data-test-status-mode={{@statusMode}}>
<div class="boxed-section-head">
<h2>Status</h2>
<div class="select-mode">
<button type="button"
data-test-status-mode-current
class="button is-small is-borderless {{if (eq @statusMode "current") "is-active"}}"
{{on "click" (action (optional @setStatusMode) "current")}}
>
Current
</button>
<button type="button"
data-test-status-mode-historical
class="button is-small is-borderless {{if (eq @statusMode "historical") "is-active"}}"
{{on "click" (action (optional @setStatusMode) "historical")}}>
Historical
</button>
</div>
</div>
<div class="boxed-section-body">
{{#if (eq @statusMode "historical")}}
<JobPage::Parts::SummaryChart @job={{@job}} />
{{else}}
<h3 class="title is-4 running-allocs-title"><strong>{{@job.runningAllocs}}/{{this.totalAllocs}}</strong> Allocations Running</h3>
<JobStatus::AllocationStatusRow @allocBlocks={{this.allocBlocks}} @steady={{true}} />
<div class="legend-and-summary">
<legend>
{{#each this.allocTypes as |type|}}
<ConditionalLinkTo
@condition={{not (eq type.label "unplaced")}}
@route="jobs.job.allocations"
@model={{@job}}
@query={{hash status=(concat '["' type.label '"]') version=(concat '[' (keys this.versions) ']')}}
@class="legend-item {{if (eq (get (get (get (get this.allocBlocks type.label) 'healthy') 'nonCanary') "length") 0) "faded"}}"
@label="View {{type.label}} allocations"
>
<span class="represented-allocation {{type.label}}"></span>
{{get (get (get (get this.allocBlocks type.label) 'healthy') 'nonCanary') "length"}} {{capitalize type.label}}
</ConditionalLinkTo>
{{/each}}
</legend>
<section class="versions">
<h4>Versions</h4>
<ul>
{{#each-in this.versions as |version allocs|}}
<li>
<LinkTo @route="jobs.job.allocations" @model={{@job}} @query={{hash version=(concat '[' version ']') status=(concat '["running", "pending", "failed"]') }}>
<Hds::Badge @text={{concat "v" version}} />
<Hds::BadgeCount @text={{allocs.length}} @type="inverted" />
</LinkTo>
</li>
{{/each-in}}
</ul>
</section>
</div>
{{/if}}
</div>
</div>

View File

@ -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],
}),
[]
);
}
}

View File

@ -0,0 +1,36 @@
<Trigger @onError={{action this.onError}} @do={{this.fetchJobDefinition}} as |trigger|>
{{did-insert trigger.fns.do}}
<div class="update-parameters">
<h4 class="title is-5">Update Params</h4>
<code>
{{#if (and trigger.data.isSuccess (not trigger.data.isError))}}
<ul>
{{#each this.updateParamGroups as |group|}}
<li>
<span class="group">Group "{{group.name}}"</span>
<ul>
{{#each-in group.update as |k v|}}
<li>
<span class="key">{{k}}</span>
<span class="value">{{v}}</span>
</li>
{{/each-in}}
</ul>
</li>
{{/each}}
</ul>
{{/if}}
{{#if trigger.data.isBusy}}
<span class="notification">Loading Parameters</span>
{{/if}}
{{#if trigger.data.isError}}
<span class="notification">Error loading parameters</span>
{{/if}}
</code>
</div>
</Trigger>

View File

@ -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();
}
}

View File

@ -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));
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -58,3 +58,4 @@
@import './components/authorization';
@import './components/policies';
@import './components/metadata-editor';
@import './components/job-status-panel';

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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
)
)
)
}}

View File

@ -0,0 +1,62 @@
{{#if @job.hasChildren}}
<ChildrenStatusBar
@allocationContainer={{@job.summary}}
@job={{@job.summary}}
@class="split-view" as |chart|
>
<ol data-test-legend class="legend">
{{#each chart.data as |datum index|}}
<li
class="{{datum.className}}
{{if (eq datum.label chart.activeDatum.label) "is-active"}}
{{if (eq datum.value 0) "is-empty"}}"
>
<JobPage::Parts::SummaryLegendItem
@datum={{datum}}
@index={{index}}
/>
</li>
{{/each}}
</ol>
</ChildrenStatusBar>
{{else}}
<AllocationStatusBar
@allocationContainer={{@job.summary}}
@job={{@job}}
@onSliceClick={{this.onSliceClick}}
@class="split-view" as |chart|
>
<ol data-test-legend class="legend">
{{#each chart.data as |datum index|}}
<li
data-test-legend-label="{{datum.className}}"
class="{{datum.className}}
{{if (eq datum.label chart.activeDatum.label) "is-active"}}
{{if (eq datum.value 0) "is-empty" "is-clickable"}}"
>
{{#if (and (gt datum.value 0) datum.legendLink)}}
<LinkTo
@route="jobs.job.allocations"
@model={{@job}}
@query={{datum.legendLink.queryParams}}
>
<JobPage::Parts::SummaryLegendItem
@datum={{datum}}
@index={{index}}
/>
</LinkTo>
{{else}}
<JobPage::Parts::SummaryLegendItem
@datum={{datum}}
@index={{index}}
/>
{{/if}}
</li>
{{/each}}
</ol>
</AllocationStatusBar>
{{/if}}

View File

@ -51,67 +51,6 @@
</div>
</a.head>
<a.body>
{{#if a.item.hasChildren}}
<ChildrenStatusBar
@allocationContainer={{a.item.summary}}
@job={{a.item.summary}}
@class="split-view" as |chart|
>
<ol data-test-legend class="legend">
{{#each chart.data as |datum index|}}
<li
class="{{datum.className}}
{{if (eq datum.label chart.activeDatum.label) "is-active"}}
{{if (eq datum.value 0) "is-empty"}}"
>
<JobPage::Parts::SummaryLegendItem
@datum={{datum}}
@index={{index}}
/>
</li>
{{/each}}
</ol>
</ChildrenStatusBar>
{{else}}
<AllocationStatusBar
@allocationContainer={{a.item.summary}}
@job={{this.job}}
@onSliceClick={{this.onSliceClick}}
@class="split-view" as |chart|
>
<ol data-test-legend class="legend">
{{#each chart.data as |datum index|}}
<li
data-test-legend-label="{{datum.className}}"
class="{{datum.className}}
{{if (eq datum.label chart.activeDatum.label) "is-active"}}
{{if (eq datum.value 0) "is-empty" "is-clickable"}}"
>
{{#if (and (gt datum.value 0) datum.legendLink)}}
<LinkTo
@route="jobs.job.allocations"
@model={{this.job}}
@query={{datum.legendLink.queryParams}}
>
<JobPage::Parts::SummaryLegendItem
@datum={{datum}}
@index={{index}}
/>
</LinkTo>
{{else}}
<JobPage::Parts::SummaryLegendItem
@datum={{datum}}
@index={{index}}
/>
{{/if}}
</li>
{{/each}}
</ol>
</AllocationStatusBar>
{{/if}}
<JobPage::Parts::SummaryChart @job={{a.item}} />
</a.body>
</ListAccordion>

View File

@ -9,8 +9,9 @@
<jobPage.ui.Title />
<jobPage.ui.StatsBox />
<jobPage.ui.DasRecommendations />
<jobPage.ui.Summary />
<jobPage.ui.StatusPanel @statusMode={{@statusMode}} @setStatusMode={{@setStatusMode}} />
<jobPage.ui.PlacementFailures />
{{!-- latestDeployment only included here for visual comparison during build-out --}}
<jobPage.ui.LatestDeployment />
<jobPage.ui.TaskGroups @sortProperty={{@sortProperty}} @sortDescending={{@sortDescending}} />
<jobPage.ui.RecentAllocations @activeTask={{@activeTask}} @setActiveTaskQueryParam={{@setActiveTaskQueryParam}} />

View File

@ -38,6 +38,13 @@
@selection={{this.selectionTaskGroup}}
@onSelect={{action this.setFacetQueryParam "qpTaskGroup"}}
/>
<MultiSelectDropdown
data-test-allocation-version-facet
@label="Job Version"
@options={{this.optionsVersions}}
@selection={{this.selectionVersion}}
@onSelect={{action this.setFacetQueryParam "qpVersion"}}
/>
</div>
</div>
</div>

View File

@ -12,4 +12,6 @@
currentPage=this.currentPage
activeTask=this.activeTask
setActiveTaskQueryParam=this.setActiveTaskQueryParam
statusMode=this.statusMode
setStatusMode=this.setStatusMode
}}

View File

@ -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'),

View File

@ -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({

View File

@ -222,6 +222,7 @@ export default Factory.extend({
withTaskServices: job.withTaskServices,
createRecommendations: job.createRecommendations,
shallow: job.shallow,
allocStatusDistribution: job.allocStatusDistribution,
};
if (job.groupTaskCount) {

View File

@ -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(),
});

View File

@ -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.allocStatusDistribution) {
const statusProbabilities = group.allocStatusDistribution || {
running: 0.6,
failed: 0.05,
unknown: 0.25,
lost: 0.1,
};
if (group.withRescheduling) {
server.create('allocation', 'rescheduled', props);
} else {
server.create('allocation', props);
}
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) {

View File

@ -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);

View File

@ -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"),

View File

@ -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');
});
});
});

View File

@ -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();

View File

@ -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) {

View File

@ -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();

View File

@ -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'),

View File

@ -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]');

View File

@ -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`
<JobStatus::Panel @job={{this.job}} />)
`);
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`
<JobStatus::Panel @job={{this.job}} />
`);
// 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`
<JobStatus::Panel @job={{this.job}} />
`);
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`
<JobStatus::Panel @job={{this.job}} />
`);
assert.notOk(find('.active-deployment'), 'No active deployment');
assert.ok(
find('.running-allocs-title'),
'Steady-state mode shown instead'
);
});
}
);

View File

@ -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(),