System Batch UI, Client Status Bar Chart and Client Tab page view (#11078)

This commit is contained in:
Jai
2021-10-07 17:11:38 -04:00
committed by GitHub
parent c50b75178f
commit 0564f9fa68
48 changed files with 2112 additions and 44 deletions

View File

@@ -1,3 +1,13 @@
printWidth: 100
singleQuote: true
trailingComma: es5
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "es5",
"overrides": [
{
"files": "*.hbs",
"options": {
"singleQuote": false
}
}
]
}

View File

@@ -65,7 +65,9 @@ export default class AllocationRow extends Component {
do {
if (this.stats) {
try {
yield this.get('stats.poll').perform();
yield this.get('stats.poll')
.linked()
.perform();
this.set('statsError', false);
} catch (error) {
this.set('statsError', true);

View File

@@ -22,6 +22,7 @@ const sumAggregate = (total, val) => total + val;
export default class DistributionBar extends Component.extend(WindowResizable) {
chart = null;
@overridable(() => null) data;
onSliceClick = null;
activeDatum = null;
isNarrow = false;
@@ -33,11 +34,13 @@ export default class DistributionBar extends Component.extend(WindowResizable) {
const data = copy(this.data, true);
const sum = data.mapBy('value').reduce(sumAggregate, 0);
return data.map(({ label, value, className, layers }, index) => ({
return data.map(({ label, value, className, layers, legendLink, help }, index) => ({
label,
value,
className,
layers,
legendLink,
help,
index,
percent: value / sum,
offset:
@@ -121,8 +124,14 @@ export default class DistributionBar extends Component.extend(WindowResizable) {
const activeDatum = this.activeDatum;
const isActive = activeDatum && activeDatum.label === d.label;
const isInactive = activeDatum && activeDatum.label !== d.label;
return [ className, isActive && 'active', isInactive && 'inactive' ].compact().join(' ');
});
const isClickable = !!this.onSliceClick;
return [
className,
isActive && 'active',
isInactive && 'inactive',
isClickable && 'clickable'
].compact().join(' ');
}).attr('data-test-slice-label', d => d.className);
this.set('slices', slices);
@@ -172,6 +181,10 @@ export default class DistributionBar extends Component.extend(WindowResizable) {
.attr('height', '6px')
.attr('y', '50%');
}
if (this.onSliceClick) {
slices.on('click', this.onSliceClick);
}
}
/* eslint-enable */

View File

@@ -0,0 +1,120 @@
import { computed } from '@ember/object';
import DistributionBar from './distribution-bar';
import classic from 'ember-classic-decorator';
@classic
export default class JobClientStatusBar extends DistributionBar {
layoutName = 'components/distribution-bar';
'data-test-job-client-status-bar' = true;
job = null;
jobClientStatus = null;
@computed('job.namespace', 'jobClientStatus.byStatus')
get data() {
const {
queued,
starting,
running,
complete,
degraded,
failed,
lost,
notScheduled,
} = this.jobClientStatus.byStatus;
return [
{
label: 'Queued',
value: queued.length,
className: 'queued',
legendLink: {
queryParams: {
status: JSON.stringify(['queued']),
namespace: this.job.namespace.get('id'),
},
},
},
{
label: 'Starting',
value: starting.length,
className: 'starting',
legendLink: {
queryParams: {
status: JSON.stringify(['starting']),
namespace: this.job.namespace.get('id'),
},
},
layers: 2,
},
{
label: 'Running',
value: running.length,
className: 'running',
legendLink: {
queryParams: {
status: JSON.stringify(['running']),
namespace: this.job.namespace.get('id'),
},
},
},
{
label: 'Complete',
value: complete.length,
className: 'complete',
legendLink: {
queryParams: {
status: JSON.stringify(['complete']),
namespace: this.job.namespace.get('id'),
},
},
},
{
label: 'Degraded',
value: degraded.length,
className: 'degraded',
legendLink: {
queryParams: {
status: JSON.stringify(['degraded']),
namespace: this.job.namespace.get('id'),
},
},
help: 'Some allocations for this job were not successfull or did not run.',
},
{
label: 'Failed',
value: failed.length,
className: 'failed',
legendLink: {
queryParams: {
status: JSON.stringify(['failed']),
namespace: this.job.namespace.get('id'),
},
},
},
{
label: 'Lost',
value: lost.length,
className: 'lost',
legendLink: {
queryParams: {
status: JSON.stringify(['lost']),
namespace: this.job.namespace.get('id'),
},
},
},
{
label: 'Not Scheduled',
value: notScheduled.length,
className: 'not-scheduled',
legendLink: {
queryParams: {
status: JSON.stringify(['notScheduled']),
namespace: this.job.namespace.get('id'),
},
},
help: 'No allocations for this job were scheduled into these clients.',
},
];
}
}

View File

@@ -0,0 +1,89 @@
import EmberObject from '@ember/object';
import Component from '@glimmer/component';
export default class ClientRow extends Component {
// Attribute set in the template as @onClick.
onClick() {}
get row() {
return this.args.row.model;
}
get shouldDisplayAllocationSummary() {
return this.args.row.model.jobStatus !== 'notScheduled';
}
get allocationSummaryPlaceholder() {
switch (this.args.row.model.jobStatus) {
case 'notScheduled':
return 'Not Scheduled';
default:
return '';
}
}
get humanizedJobStatus() {
switch (this.args.row.model.jobStatus) {
case 'notScheduled':
return 'not scheduled';
default:
return this.args.row.model.jobStatus;
}
}
get jobStatusClass() {
switch (this.args.row.model.jobStatus) {
case 'notScheduled':
return 'not-scheduled';
default:
return this.args.row.model.jobStatus;
}
}
get allocationContainer() {
const statusSummary = {
queuedAllocs: 0,
completeAllocs: 0,
failedAllocs: 0,
runningAllocs: 0,
startingAllocs: 0,
lostAllocs: 0,
};
switch (this.args.row.model.jobStatus) {
case 'notSchedule':
break;
case 'queued':
statusSummary.queuedAllocs = this.args.row.model.allocations.length;
break;
case 'starting':
statusSummary.startingAllocs = this.args.row.model.allocations.length;
break;
default:
for (const alloc of this.args.row.model.allocations) {
switch (alloc.clientStatus) {
case 'running':
statusSummary.runningAllocs++;
break;
case 'lost':
statusSummary.lostAllocs++;
break;
case 'failed':
statusSummary.failedAllocs++;
break;
case 'complete':
statusSummary.completeAllocs++;
break;
case 'starting':
statusSummary.startingAllocs++;
break;
}
}
}
const Allocations = EmberObject.extend({
...statusSummary,
});
return Allocations.create();
}
}

View File

@@ -1,11 +1,14 @@
import { computed } from '@ember/object';
import { alias } from '@ember/object/computed';
import { inject as service } from '@ember/service';
import PeriodicChildJobPage from './periodic-child';
import classic from 'ember-classic-decorator';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class ParameterizedChild extends PeriodicChildJobPage {
@alias('job.decodedPayload') payload;
@service store;
@computed('payload')
get payloadJSON() {
@@ -17,4 +20,10 @@ export default class ParameterizedChild extends PeriodicChildJobPage {
}
return json;
}
@jobClientStatus('nodes', 'job') jobClientStatus;
get nodes() {
return this.store.peekAll('node');
}
}

View File

@@ -0,0 +1,28 @@
import Component from '@ember/component';
import { action, computed } from '@ember/object';
import { classNames } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';
@classic
@classNames('boxed-section')
export default class JobClientStatusSummary extends Component {
job = null;
jobClientStatus = null;
gotoClients() {}
@computed
get isExpanded() {
const storageValue = window.localStorage.nomadExpandJobClientStatusSummary;
return storageValue != null ? JSON.parse(storageValue) : true;
}
@action
onSliceClick(slice) {
this.gotoClients([slice.className.camelize()]);
}
persist(item, isOpen) {
window.localStorage.nomadExpandJobClientStatusSummary = isOpen;
this.notifyPropertyChange('isExpanded');
}
}

View File

@@ -7,9 +7,12 @@ import classic from 'ember-classic-decorator';
@classNames('boxed-section')
export default class Summary extends Component {
job = null;
forceCollapsed = false;
@computed
@computed('forceCollapsed')
get isExpanded() {
if (this.forceCollapsed) return false;
const storageValue = window.localStorage.nomadExpandJobSummary;
return storageValue != null ? JSON.parse(storageValue) : true;
}

View File

@@ -1,9 +1,13 @@
import AbstractJobPage from './abstract';
import { computed } from '@ember/object';
import { inject as service } from '@ember/service';
import classic from 'ember-classic-decorator';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class PeriodicChild extends AbstractJobPage {
@service store;
@computed('job.{name,id}', 'job.parent.{name,id}')
get breadcrumbs() {
const job = this.job;
@@ -21,4 +25,10 @@ export default class PeriodicChild extends AbstractJobPage {
},
];
}
@jobClientStatus('nodes', 'job') jobClientStatus;
get nodes() {
return this.store.peekAll('node');
}
}

View File

@@ -0,0 +1,15 @@
import AbstractJobPage from './abstract';
import classic from 'ember-classic-decorator';
import { inject as service } from '@ember/service';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class Sysbatch extends AbstractJobPage {
@service store;
@jobClientStatus('nodes', 'job') jobClientStatus;
get nodes() {
return this.store.peekAll('node');
}
}

View File

@@ -1,5 +1,15 @@
import AbstractJobPage from './abstract';
import classic from 'ember-classic-decorator';
import { inject as service } from '@ember/service';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
@classic
export default class System extends AbstractJobPage {}
export default class System extends AbstractJobPage {
@service store;
@jobClientStatus('nodes', 'job') jobClientStatus;
get nodes() {
return this.store.peekAll('node');
}
}

View File

@@ -54,7 +54,9 @@ export default class TaskRow extends Component {
do {
if (this.stats) {
try {
yield this.get('stats.poll').perform();
yield this.get('stats.poll')
.linked()
.perform();
this.set('statsError', false);
} catch (error) {
this.set('statsError', true);

View File

@@ -83,6 +83,7 @@ export default class IndexController extends Controller.extend(Sortable, Searcha
{ key: 'periodic', label: 'Periodic' },
{ key: 'service', label: 'Service' },
{ key: 'system', label: 'System' },
{ key: 'sysbatch', label: 'System Batch' },
];
}

View File

@@ -0,0 +1,192 @@
/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */
import Controller from '@ember/controller';
import { action, computed } from '@ember/object';
import { scheduleOnce } from '@ember/runloop';
import intersection from 'lodash.intersection';
import { alias } from '@ember/object/computed';
import SortableFactory from 'nomad-ui/mixins/sortable-factory';
import Searchable from 'nomad-ui/mixins/searchable';
import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
import { serialize, deserializedQueryParam as selection } from 'nomad-ui/utils/qp-serialize';
import classic from 'ember-classic-decorator';
@classic
export default class ClientsController extends Controller.extend(
SortableFactory(['id', 'name', 'jobStatus']),
Searchable,
WithNamespaceResetting
) {
queryParams = [
{
currentPage: 'page',
},
{
searchTerm: 'search',
},
{
qpStatus: 'status',
},
{
qpDatacenter: 'dc',
},
{
qpClientClass: 'clientclass',
},
{
sortProperty: 'sort',
},
{
sortDescending: 'desc',
},
];
qpStatus = '';
qpDatacenter = '';
qpClientClass = '';
currentPage = 1;
pageSize = 25;
sortProperty = 'jobStatus';
sortDescending = false;
@selection('qpStatus') selectionStatus;
@selection('qpDatacenter') selectionDatacenter;
@selection('qpClientClass') selectionClientClass;
@alias('model') job;
@jobClientStatus('allNodes', 'job') jobClientStatus;
@alias('filteredNodes') listToSort;
@alias('listSorted') listToSearch;
@alias('listSearched') sortedClients;
@computed('store')
get allNodes() {
return this.store.peekAll('node').length
? this.store.peekAll('node')
: this.store.findAll('node');
}
@computed('allNodes', 'jobClientStatus.byNode')
get nodes() {
return this.allNodes.filter(node => this.jobClientStatus.byNode[node.id]);
}
@computed
get searchProps() {
return ['node.id', 'node.name'];
}
@computed(
'nodes',
'job.allocations',
'jobClientStatus.byNode',
'selectionStatus',
'selectionDatacenter',
'selectionClientClass'
)
get filteredNodes() {
const {
selectionStatus: statuses,
selectionDatacenter: datacenters,
selectionClientClass: clientClasses,
} = this;
return this.nodes
.filter(node => {
if (statuses.length && !statuses.includes(this.jobClientStatus.byNode[node.id])) {
return false;
}
if (datacenters.length && !datacenters.includes(node.datacenter)) {
return false;
}
if (clientClasses.length && !clientClasses.includes(node.nodeClass)) {
return false;
}
return true;
})
.map(node => {
const allocations = this.job.allocations.filter(alloc => alloc.get('node.id') == node.id);
return {
node,
jobStatus: this.jobClientStatus.byNode[node.id],
allocations,
createTime: eldestCreateTime(allocations),
modifyTime: mostRecentModifyTime(allocations),
};
});
}
@computed
get optionsJobStatus() {
return [
{ key: 'queued', label: 'Queued' },
{ key: 'notScheduled', label: 'Not Scheduled' },
{ key: 'starting', label: 'Starting' },
{ key: 'running', label: 'Running' },
{ key: 'complete', label: 'Complete' },
{ key: 'degraded', label: 'Degraded' },
{ key: 'failed', label: 'Failed' },
{ key: 'lost', label: 'Lost' },
];
}
@computed('selectionDatacenter', 'nodes')
get optionsDatacenter() {
const datacenters = Array.from(new Set(this.nodes.mapBy('datacenter'))).compact();
// Update query param when the list of datacenters changes.
scheduleOnce('actions', () => {
// eslint-disable-next-line ember/no-side-effects
this.set('qpDatacenter', serialize(intersection(datacenters, this.selectionDatacenter)));
});
return datacenters.sort().map(dc => ({ key: dc, label: dc }));
}
@computed('selectionClientClass', 'nodes')
get optionsClientClass() {
const clientClasses = Array.from(new Set(this.nodes.mapBy('nodeClass'))).compact();
// Update query param when the list of datacenters changes.
scheduleOnce('actions', () => {
// eslint-disable-next-line ember/no-side-effects
this.set('qpClientClass', serialize(intersection(clientClasses, this.selectionClientClass)));
});
return clientClasses.sort().map(clientClass => ({ key: clientClass, label: clientClass }));
}
@action
gotoClient(client) {
this.transitionToRoute('clients.client', client);
}
setFacetQueryParam(queryParam, selection) {
this.set(queryParam, serialize(selection));
}
}
function eldestCreateTime(allocations) {
let eldest = null;
for (const alloc of allocations) {
if (!eldest || alloc.createTime < eldest) {
eldest = alloc.createTime;
}
}
return eldest;
}
function mostRecentModifyTime(allocations) {
let mostRecent = null;
for (const alloc of allocations) {
if (!mostRecent || alloc.modifyTime > mostRecent) {
mostRecent = alloc.modifyTime;
}
}
return mostRecent;
}

View File

@@ -39,4 +39,14 @@ export default class IndexController extends Controller.extend(WithNamespaceRese
queryParams: { jobNamespace: job.get('namespace.name') },
});
}
@action
gotoClients(statusFilter) {
this.transitionToRoute('jobs.job.clients', this.job, {
queryParams: {
status: JSON.stringify(statusFilter),
namespace: this.job.get('namespace.name'),
},
});
}
}

View File

@@ -7,7 +7,7 @@ import RSVP from 'rsvp';
import { assert } from '@ember/debug';
import classic from 'ember-classic-decorator';
const JOB_TYPES = ['service', 'batch', 'system'];
const JOB_TYPES = ['service', 'batch', 'system', 'sysbatch'];
@classic
export default class Job extends Model {
@@ -39,6 +39,11 @@ export default class Job extends Model {
return this.periodic || (this.parameterized && !this.dispatched);
}
@computed('type')
get hasClientStatus() {
return this.type === 'system' || this.type === 'sysbatch';
}
@belongsTo('job', { inverse: 'children' }) parent;
@hasMany('job', { inverse: 'parent' }) children;

View File

@@ -23,6 +23,7 @@ Router.map(function() {
this.route('dispatch');
this.route('evaluations');
this.route('allocations');
this.route('clients');
});
});

View File

@@ -0,0 +1,30 @@
import Route from '@ember/routing/route';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
import { watchRecord, watchRelationship, watchAll } from 'nomad-ui/utils/properties/watch';
import { collect } from '@ember/object/computed';
export default class ClientsRoute extends Route.extend(WithWatchers) {
async model() {
await this.store.findAll('node');
return this.modelFor('jobs.job');
}
startWatchers(controller, model) {
if (!model) {
return;
}
controller.set('watchers', {
model: this.watch.perform(model),
allocations: this.watchAllocations.perform(model),
nodes: this.watchNodes.perform(),
});
}
@watchRecord('job') watch;
@watchAll('node') watchNodes;
@watchRelationship('allocations') watchAllocations;
@collect('watch', 'watchNodes', 'watchAllocations')
watchers;
}

View File

@@ -1,9 +1,20 @@
import Route from '@ember/routing/route';
import { collect } from '@ember/object/computed';
import { watchRecord, watchRelationship, watchQuery } from 'nomad-ui/utils/properties/watch';
import {
watchRecord,
watchRelationship,
watchAll,
watchQuery,
} from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
export default class IndexRoute extends Route.extend(WithWatchers) {
async model() {
// Optimizing future node look ups by preemptively loading everything
await this.store.findAll('node');
return this.modelFor('jobs.job');
}
startWatchers(controller, model) {
if (!model) {
return;
@@ -18,6 +29,7 @@ export default class IndexRoute extends Route.extend(WithWatchers) {
list:
model.get('hasChildren') &&
this.watchAllJobs.perform({ namespace: model.namespace.get('name') }),
nodes: model.get('hasClientStatus') && this.watchNodes.perform(),
});
}
@@ -35,6 +47,7 @@ export default class IndexRoute extends Route.extend(WithWatchers) {
@watchRecord('job') watch;
@watchQuery('job') watchAllJobs;
@watchAll('node') watchNodes;
@watchRecord('job-summary') watchSummary;
@watchRelationship('allocations') watchAllocations;
@watchRelationship('evaluations') watchEvaluations;
@@ -46,7 +59,8 @@ export default class IndexRoute extends Route.extend(WithWatchers) {
'watchSummary',
'watchAllocations',
'watchEvaluations',
'watchLatestDeployment'
'watchLatestDeployment',
'watchNodes'
)
watchers;
}

View File

@@ -4,6 +4,8 @@ $running: $primary;
$complete: $nomad-green-dark;
$failed: $danger;
$lost: $dark;
$not-scheduled: $blue-200;
$degraded: $warning;
.chart {
.queued {
@@ -37,6 +39,14 @@ $lost: $dark;
.lost {
fill: $lost;
}
.not-scheduled {
fill: $not-scheduled;
}
.degraded {
fill: $degraded;
}
}
.color-swatch {
@@ -102,6 +112,14 @@ $lost: $dark;
background: $lost;
}
&.not-scheduled {
background: $not-scheduled;
}
&.degraded {
background: $degraded;
}
@each $name, $pair in $colors {
$color: nth($pair, 1);

View File

@@ -13,6 +13,10 @@
opacity: 1;
}
.clickable {
cursor: pointer;
}
.inactive {
opacity: 0.2;
}
@@ -63,16 +67,48 @@
// Ensure two columns, but don't use the full width
width: 35%;
.label,
.value {
display: inline;
font-weight: $weight-normal;
.legend-item {
display: flex;
align-items: center;
.color-swatch {
margin-right: 0.5rem;
}
.text {
flex-grow: 1;
.label,
.value {
display: inline;
font-weight: $weight-normal;
}
}
.icon {
width: 1.2rem;
height: 1.2rem;
}
}
&.is-active {
background-color: rgba($info, 0.1);
}
&.is-clickable {
a {
display: block;
text-decoration: none;
color: inherit;
}
&:not(.is-empty) {
&:hover {
background-color: rgba($info, 0.1);
}
}
}
&.is-empty {
color: darken($grey-blue, 20%);
border: none;

View File

@@ -33,6 +33,11 @@
.accordion-head-content {
width: 100%;
margin-right: 1.5em;
.tooltip {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
}
.accordion-toggle {

View File

@@ -0,0 +1,41 @@
<tr data-test-client={{this.row.node.id}} class="job-client-status-row is-interactive" {{on "click" (fn @onClick this.row.node)}}>
<td data-test-short-id>
<LinkTo @route="allocations.allocation" @model={{this.allocation}} class="is-primary">
{{this.row.node.shortId}}
</LinkTo>
</td>
<td data-test-name class="is-200px is-truncatable">
{{this.row.node.name}}
</td>
<td data-test-create-time>
{{#if this.row.createTime}}
<span class="tooltip" role="tooltip" aria-label="{{format-month-ts this.row.createTime}}">
{{moment-from-now this.row.createTime}}
</span>
{{else}}
-
{{/if}}
</td>
<td data-test-modify-time>
{{#if this.row.modifyTime}}
<span class="tooltip" role="tooltip" aria-label="{{format-month-ts this.row.modifyTime}}">
{{moment-from-now this.row.modifyTime}}
</span>
{{else}}
-
{{/if}}
</td>
<td data-test-job-status class="is-one-line">
<span class="color-swatch {{this.jobStatusClass}}"></span>
{{this.humanizedJobStatus}}
</td>
<td data-test-job-alloc-summary class="allocation-summary is-one-line">
{{#if this.shouldDisplayAllocationSummary}}
<div class="inline-chart">
<AllocationStatusBar @allocationContainer={{this.allocationContainer}} @isNarrow={{true}} />
</div>
{{else}}
<div class="is-faded">{{this.allocationSummaryPlaceholder}}</div>
{{/if}}
</td>
</tr>

View File

@@ -19,7 +19,15 @@
</div>
</div>
<JobPage::Parts::Summary @job={{this.job}} />
{{#if this.job.hasClientStatus}}
<JobPage::Parts::JobClientStatusSummary
@gotoClients={{this.gotoClients}}
@job={{this.job}}
@jobClientStatus={{this.jobClientStatus}}
/>
{{/if}}
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.job.hasClientStatus}} />
<JobPage::Parts::PlacementFailures @job={{this.job}} />
@@ -62,4 +70,4 @@
{{/if}}
</div>
</div>
</JobPage::Parts::Body>
</JobPage::Parts::Body>

View File

@@ -0,0 +1,55 @@
<ListAccordion
data-test-job-summary
@source={{array this.job}}
@key="id"
@startExpanded={{this.isExpanded}}
@onToggle={{action this.persist}} as |a|
>
<a.head @buttonLabel={{if a.isOpen "collapse" "expand"}}>
<div class="columns">
<div class="column is-minimum nowrap">
Job Status in Client
<span class="badge {{if a.isOpen "is-white" "is-light"}}">
{{this.jobClientStatus.totalNodes}}
</span>
<span class="tooltip multiline" aria-label="Aggreate status of job's allocations in each client.">
{{x-icon "info-circle-outline" class="is-faded"}}
</span>
</div>
{{#unless a.isOpen}}
<div class="column">
<div class="inline-chart bumper-left">
<JobClientStatusBar
@onSliceClick={{action this.onSliceClick}}
@job={{this.job}}
@jobClientStatus={{this.jobClientStatus}}
@isNarrow={{true}}
/>
</div>
</div>
{{/unless}}
</div>
</a.head>
<a.body>
<JobClientStatusBar
@onSliceClick={{action this.onSliceClick}}
@job={{this.job}}
@jobClientStatus={{this.jobClientStatus}}
class="split-view" as |chart|
>
<ol data-test-legend class="legend">
{{#each chart.data as |datum index|}}
<li data-test-legent-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 (gt datum.value 0)}}
<LinkTo @route="jobs.job.clients" @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>
</JobClientStatusBar>
</a.body>
</ListAccordion>

View File

@@ -0,0 +1,12 @@
<div class="legend-item">
<span class="color-swatch {{if @datum.className @datum.className (concat "swatch-" @index)}}" />
<span class="text">
<span class="value" data-test-legend-value="{{@datum.className}}">{{@datum.value}}</span>
<span class="label">{{@datum.label}}</span>
</span>
{{#if @datum.help}}
<span class="tooltip multiline" role="tooltip" aria-label="{{@datum.help}}">
{{x-icon "info-circle-outline" class="is-faded"}}
</span>
{{/if}}
</div>

View File

@@ -40,15 +40,10 @@
<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"}}">
<span class="color-swatch {{if datum.className datum.className (concat "swatch-" index)}}" />
<span class="value" data-test-legend-value="{{datum.className}}">{{datum.value}}</span>
<span class="label">
{{datum.label}}
</span>
<JobPage::Parts::SummaryLegendItem @datum={{datum}} @index={{index}} />
</li>
{{/each}}
</ol>
{{/component}}
</a.body>
</ListAccordion>

View File

@@ -19,7 +19,15 @@
</div>
</div>
<JobPage::Parts::Summary @job={{this.job}} />
{{#if this.job.hasClientStatus}}
<JobPage::Parts::JobClientStatusSummary
@gotoClients={{this.gotoClients}}
@job={{this.job}}
@jobClientStatus={{this.jobClientStatus}}
/>
{{/if}}
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.job.hasClientStatus}} />
<JobPage::Parts::PlacementFailures @job={{this.job}} />
@@ -30,4 +38,4 @@
@gotoTaskGroup={{this.gotoTaskGroup}} />
<JobPage::Parts::RecentAllocations @job={{this.job}} />
</JobPage::Parts::Body>
</JobPage::Parts::Body>

View File

@@ -0,0 +1,32 @@
<JobPage::Parts::Body @job={{this.job}}>
<JobPage::Parts::Error @errorMessage={{this.errorMessage}} @onDismiss={{action "clearErrorMessage"}}/>
<JobPage::Parts::Title @job={{this.job}} @handleError={{action "handleError"}} />
<div class="boxed-section job-stats">
<div class="boxed-section-body">
<span data-test-job-stat="type"><strong>Type:</strong>{{this.job.type}} | </span>
<span data-test-job-stat="priority"><strong>Priority:</strong>{{this.job.priority}}</span>
{{#if (and this.job.namespace this.system.shouldShowNamespaces)}}
<span data-test-job-stat="namespace"> | <strong>Namespace:</strong>{{this.job.namespace.name}}</span>
{{/if}}
</div>
</div>
<JobPage::Parts::JobClientStatusSummary
@job={{this.job}}
@jobClientStatus={{this.jobClientStatus}}
@gotoClients={{this.gotoClients}} />
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed="true" />
<JobPage::Parts::PlacementFailures @job={{this.job}} />
<JobPage::Parts::TaskGroups
@job={{this.job}}
@sortProperty={{this.sortProperty}}
@sortDescending={{this.sortDescending}}
@gotoTaskGroup={{this.gotoTaskGroup}} />
<JobPage::Parts::RecentAllocations @job={{this.job}} />
</JobPage::Parts::Body>

View File

@@ -19,7 +19,12 @@
{{/each}}
{{/if}}
<JobPage::Parts::Summary @job={{this.job}} />
<JobPage::Parts::JobClientStatusSummary
@job={{this.job}}
@jobClientStatus={{this.jobClientStatus}}
@gotoClients={{this.gotoClients}} />
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed="true" />
<JobPage::Parts::PlacementFailures @job={{this.job}} />

View File

@@ -1,12 +1,15 @@
<div data-test-subnav="job" class="tabs is-subnav">
<ul>
<li data-test-tab="overview"><LinkTo @route="jobs.job.index" @model={{@job}} @activeClass="is-active" @current-when="jobs.job.index jobs.job.dispatch">Overview</LinkTo></li>
<li data-test-tab="definition"><LinkTo @route="jobs.job.definition" @model={{@job}} @activeClass="is-active">Definition</LinkTo></li>
<li data-test-tab="versions"><LinkTo @route="jobs.job.versions" @model={{@job}} @activeClass="is-active">Versions</LinkTo></li>
<li data-test-tab="overview"><LinkTo @route="jobs.job.index" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active" @current-when="jobs.job.index jobs.job.dispatch">Overview</LinkTo></li>
<li data-test-tab="definition"><LinkTo @route="jobs.job.definition" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Definition</LinkTo></li>
<li data-test-tab="versions"><LinkTo @route="jobs.job.versions" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Versions</LinkTo></li>
{{#if this.job.supportsDeployments}}
<li data-test-tab="deployments"><LinkTo @route="jobs.job.deployments" @model={{@job}} @activeClass="is-active">Deployments</LinkTo></li>
<li data-test-tab="deployments"><LinkTo @route="jobs.job.deployments" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Deployments</LinkTo></li>
{{/if}}
<li data-test-tab="allocations"><LinkTo @route="jobs.job.allocations" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Allocations</LinkTo></li>
<li data-test-tab="evaluations"><LinkTo @route="jobs.job.evaluations" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Evaluations</LinkTo></li>
{{#if (and this.job.hasClientStatus (not this.job.hasChildren))}}
<li data-test-tab="clients"><LinkTo @route="jobs.job.clients" @query={{hash namespace=this.job.namespace.id}} @model={{@job}} @activeClass="is-active">Clients</LinkTo></li>
{{/if}}
<li data-test-tab="allocations"><LinkTo @route="jobs.job.allocations" @model={{@job}} @activeClass="is-active">Allocations</LinkTo></li>
<li data-test-tab="evaluations"><LinkTo @route="jobs.job.evaluations" @model={{@job}} @activeClass="is-active">Evaluations</LinkTo></li>
</ul>
</div>

View File

@@ -0,0 +1,106 @@
{{page-title "Job " this.job.name " clients"}}
<JobSubnav @job={{this.job}} />
<section class="section">
{{#if this.nodes.length}}
<div class="toolbar">
<div class="toolbar-item">
<SearchBox
data-test-clients-search
@searchTerm={{mut this.searchTerm}}
@onChange={{action this.resetPagination}}
@placeholder="Search clients..."
/>
</div>
<div class="toolbar-item is-right-aligned">
<div class="button-bar">
<MultiSelectDropdown
data-test-job-status-facet
@label="Job Status"
@options={{this.optionsJobStatus}}
@selection={{this.selectionStatus}}
@onSelect={{action this.setFacetQueryParam "qpStatus"}}
/>
<MultiSelectDropdown
data-test-datacenter-facet
@label="Datacenter"
@options={{this.optionsDatacenter}}
@selection={{this.selectionDatacenter}}
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}}
/>
<MultiSelectDropdown
data-test-client-class-facet
@label="Client Class"
@options={{this.optionsClientClass}}
@selection={{this.selectionClientClass}}
@onSelect={{action this.setFacetQueryParam "qpClientClass"}}
/>
</div>
</div>
</div>
{{#if this.sortedClients}}
<ListPagination
@source={{this.sortedClients}}
@size={{this.pageSize}}
@page={{this.currentPage}}
@class="clients" as |p|
>
<ListTable
@source={{p.list}}
@sortProperty={{this.sortProperty}}
@sortDescending={{this.sortDescending}}
@class="with-foot" as |t|
>
<t.head>
<t.sort-by @prop="node.id">Client ID</t.sort-by>
<t.sort-by @prop="node.name" class="is-200px is-truncatable">Client Name</t.sort-by>
<t.sort-by @prop="createTime" @title="Create Time">Created</t.sort-by>
<t.sort-by @prop="modifyTime" @title="Modify Time">Modified</t.sort-by>
<t.sort-by @prop="jobStatus">Job Status</t.sort-by>
<th class="is-3">Allocation Summary</th>
</t.head>
<t.body as |row|>
<JobClientStatusRow
@row={{row}}
@onClick={{this.gotoClient}}
/>
</t.body>
</ListTable>
<div class="table-foot">
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{this.sortedClients.length}}
</div>
<p.prev @class="pagination-previous"> &lt; </p.prev>
<p.next @class="pagination-next"> &gt; </p.next>
<ul class="pagination-list"></ul>
</nav>
</div>
</ListPagination>
{{else}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-clients-list>
<h3 class="empty-message-headline" data-test-empty-clients-list-headline>
No Matches
</h3>
<p class="empty-message-body">
No clients match the term
<strong>
{{this.searchTerm}}
</strong>
</p>
</div>
</div>
{{/if}}
{{else}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-clients-list>
<h3 class="empty-message-headline" data-test-empty-clients-list-headline>
No Clients
</h3>
<p class="empty-message-body">
No clients available.
</p>
</div>
</div>
{{/if}}
</section>

View File

@@ -5,4 +5,6 @@
sortDescending=this.sortDescending
currentPage=this.currentPage
gotoJob=(action "gotoJob")
gotoTaskGroup=(action "gotoTaskGroup")}}
gotoTaskGroup=(action "gotoTaskGroup")
gotoClients=(action "gotoClients")
}}

View File

@@ -0,0 +1,141 @@
import { computed } from '@ember/object';
const STATUS = [
'queued',
'notScheduled',
'starting',
'running',
'complete',
'degraded',
'failed',
'lost',
];
// An Ember.Computed property that computes the aggregated status of a job in a
// client based on the desiredStatus of each allocation placed in the client.
//
// ex. clientStaus: jobClientStatus('nodes', 'job'),
export default function jobClientStatus(nodesKey, jobKey) {
return computed(
`${nodesKey}.[]`,
`${jobKey}.{datacenters,status,allocations.@each.clientStatus,taskGroups}`,
function() {
const job = this.get(jobKey);
const nodes = this.get(nodesKey);
// Filter nodes by the datacenters defined in the job.
const filteredNodes = nodes.filter(n => {
return job.datacenters.indexOf(n.datacenter) >= 0;
});
if (job.status === 'pending') {
return allQueued(filteredNodes);
}
// Group the job allocations by the ID of the client that is running them.
const allocsByNodeID = {};
job.allocations.forEach(a => {
const nodeId = a.node.get('id');
if (!allocsByNodeID[nodeId]) {
allocsByNodeID[nodeId] = [];
}
allocsByNodeID[nodeId].push(a);
});
const result = {
byNode: {},
byStatus: {},
totalNodes: filteredNodes.length,
};
filteredNodes.forEach(n => {
const status = jobStatus(allocsByNodeID[n.id], job.taskGroups.length);
result.byNode[n.id] = status;
if (!result.byStatus[status]) {
result.byStatus[status] = [];
}
result.byStatus[status].push(n.id);
});
result.byStatus = canonicalizeStatus(result.byStatus);
return result;
}
);
}
function allQueued(nodes) {
const nodeIDs = nodes.map(n => n.id);
return {
byNode: Object.fromEntries(nodeIDs.map(id => [id, 'queued'])),
byStatus: canonicalizeStatus({ queued: nodeIDs }),
totalNodes: nodes.length,
};
}
// canonicalizeStatus makes sure all possible statuses are present in the final
// returned object. Statuses missing from the input will be assigned an emtpy
// array.
function canonicalizeStatus(status) {
for (let i = 0; i < STATUS.length; i++) {
const s = STATUS[i];
if (!status[s]) {
status[s] = [];
}
}
return status;
}
// jobStatus computes the aggregated status of a job in a client.
//
// `allocs` are the list of allocations for a job that are placed in a specific
// client.
// `expected` is the number of allocations the client should have.
function jobStatus(allocs, expected) {
// The `pending` status has already been checked, so if at this point the
// client doesn't have any allocations we assume that it was not considered
// for scheduling for some reason.
if (!allocs) {
return 'notScheduled';
}
// If there are some allocations, but not how many we expected, the job is
// considered `degraded` since it did fully run in this client.
if (allocs.length < expected) {
return 'degraded';
}
// Count how many allocations are in each `clientStatus` value.
const summary = allocs
.filter(a => !a.isOld)
.reduce((acc, a) => {
const status = a.clientStatus;
if (!acc[status]) {
acc[status] = 0;
}
acc[status]++;
return acc;
}, {});
// Theses statuses are considered terminal, i.e., an allocation will never
// move from this status to another.
// If all of the expected allocations are in one of these statuses, the job
// as a whole is considered to be in the same status.
const terminalStatuses = ['failed', 'lost', 'complete'];
for (let i = 0; i < terminalStatuses.length; i++) {
const s = terminalStatuses[i];
if (summary[s] === expected) {
return s;
}
}
// It only takes one allocation to be in one of these statuses for the
// entire job to be considered in a given status.
if (summary['failed'] > 0 || summary['lost'] > 0) {
return 'degraded';
}
if (summary['running'] > 0) {
return 'running';
}
return 'starting';
}

View File

@@ -25,7 +25,8 @@ module.exports = function(environment) {
APP: {
blockingQueries: true,
mirageScenario: 'topoMedium',
// TODO: revert before merging to main.
mirageScenario: 'sysbatchSmall', // convert to 'sysbatchSmall' when working on feature
mirageWithNamespaces: false,
mirageWithTokens: true,
mirageWithRegions: true,

View File

@@ -6,7 +6,7 @@ import { DATACENTERS } from '../common';
const REF_TIME = new Date();
const JOB_PREFIXES = provide(5, faker.hacker.abbreviation);
const JOB_TYPES = ['service', 'batch', 'system'];
const JOB_TYPES = ['service', 'batch', 'system', 'sysbatch'];
const JOB_STATUSES = ['pending', 'running', 'dead'];
export default Factory.extend({
@@ -67,6 +67,20 @@ export default Factory.extend({
}),
}),
periodicSysbatch: trait({
type: 'sysbatch',
periodic: true,
// periodic details object
// serializer update for bool vs details object
periodicDetails: () => ({
Enabled: true,
ProhibitOverlap: true,
Spec: '*/5 * * * * *',
SpecType: 'cron',
TimeZone: 'UTC',
}),
}),
parameterized: trait({
type: 'batch',
parameterized: true,
@@ -79,6 +93,18 @@ export default Factory.extend({
}),
}),
parameterizedSysbatch: trait({
type: 'sysbatch',
parameterized: true,
// parameterized job object
// serializer update for bool vs details object
parameterizedJob: () => ({
MetaOptional: generateMetaFields(faker.random.number(10), 'optional'),
MetaRequired: generateMetaFields(faker.random.number(10), 'required'),
Payload: faker.random.boolean() ? 'required' : null,
}),
}),
periodicChild: trait({
// Periodic children need a parent job,
// It is the Periodic job's responsibility to create
@@ -86,6 +112,13 @@ export default Factory.extend({
type: 'batch',
}),
periodicSysbatchChild: trait({
// Periodic children need a parent job,
// It is the Periodic job's responsibility to create
// periodicChild jobs and provide a parent job.
type: 'sysbatch',
}),
parameterizedChild: trait({
// Parameterized children need a parent job,
// It is the Parameterized job's responsibility to create
@@ -96,6 +129,16 @@ export default Factory.extend({
payload: window.btoa(faker.lorem.sentence()),
}),
parameterizedSysbatchChild: trait({
// Parameterized children need a parent job,
// It is the Parameterized job's responsibility to create
// parameterizedChild jobs and provide a parent job.
type: 'sysbatch',
parameterized: true,
dispatched: true,
payload: window.btoa(faker.lorem.sentence()),
}),
createIndex: i => i,
modifyIndex: () => faker.random.number({ min: 10, max: 2000 }),
@@ -248,22 +291,44 @@ export default Factory.extend({
}
if (job.periodic) {
// Create periodicChild jobs
server.createList('job', job.childrenCount, 'periodicChild', {
let childType;
switch (job.type) {
case 'batch':
childType = 'periodicChild';
break;
case 'sysbatch':
childType = 'periodicSysbatchChild';
break;
}
// Create child jobs
server.createList('job', job.childrenCount, childType, {
parentId: job.id,
namespaceId: job.namespaceId,
namespace: job.namespace,
datacenters: job.datacenters,
createAllocations: job.createAllocations,
shallow: job.shallow,
});
}
if (job.parameterized && !job.parentId) {
// Create parameterizedChild jobs
server.createList('job', job.childrenCount, 'parameterizedChild', {
let childType;
switch (job.type) {
case 'batch':
childType = 'parameterizedChild';
break;
case 'sysbatch':
childType = 'parameterizedSysbatchChild';
break;
}
// Create child jobs
server.createList('job', job.childrenCount, childType, {
parentId: job.id,
namespaceId: job.namespaceId,
namespace: job.namespace,
datacenters: job.datacenters,
createAllocations: job.createAllocations,
shallow: job.shallow,
});

View File

@@ -1,5 +1,6 @@
import config from 'nomad-ui/config/environment';
import * as topoScenarios from './topo';
import * as sysbatchScenarios from './sysbatch';
import { pickOne } from '../utils';
const withNamespaces = getConfigValue('mirageWithNamespaces', false);
@@ -16,6 +17,7 @@ const allScenarios = {
everyFeature,
emptyCluster,
...topoScenarios,
...sysbatchScenarios,
};
const scenario = getScenarioQueryParameter() || getConfigValue('mirageScenario', 'emptyCluster');
@@ -85,6 +87,8 @@ function allJobTypes(server) {
server.create('job', { type: 'system' });
server.create('job', 'periodic');
server.create('job', 'parameterized');
server.create('job', 'periodicSysbatch');
server.create('job', 'parameterizedSysbatch');
server.create('job', { failedPlacements: true });
}

View File

@@ -0,0 +1,83 @@
export function sysbatchSmall(server) {
return sysbatchScenario(server, 15);
}
export function sysbatchLarge(server) {
return sysbatchScenario(server, 55);
}
function sysbatchScenario(server, clientCount) {
server.createList('agent', 3);
const clients = server.createList('node', clientCount, {
datacenter: 'dc1',
status: 'ready',
});
// Create some clients not targeted by the sysbatch job.
server.createList('node', 3, {
datacenter: 'dc3',
status: 'ready',
});
// Generate non-system/sysbatch job as counter-example.
server.create('job', {
status: 'running',
type: 'service',
resourceSpec: ['M: 256, C: 500'],
createAllocations: true,
});
['system', 'sysbatch'].forEach(type => {
// Job with 1 task group.
const job1 = server.create('job', {
status: 'running',
datacenters: ['dc1', 'dc2'],
type,
resourceSpec: ['M: 256, C: 500'],
createAllocations: false,
});
clients.forEach(c => {
server.create('allocation', { jobId: job1.id, nodeId: c.id });
});
// Job with 2 task groups.
const job2 = server.create('job', {
status: 'running',
datacenters: ['dc1'],
type,
resourceSpec: ['M: 256, C: 500', 'M: 256, C: 500'],
createAllocations: false,
});
clients.forEach(c => {
server.create('allocation', { jobId: job2.id, nodeId: c.id });
server.create('allocation', { jobId: job2.id, nodeId: c.id });
});
// Job with 3 task groups.
const job3 = server.create('job', {
status: 'running',
datacenters: ['dc1'],
type,
resourceSpec: ['M: 256, C: 500', 'M: 256, C: 500', 'M: 256, C: 500'],
createAllocations: false,
});
clients.forEach(c => {
server.create('allocation', { jobId: job3.id, nodeId: c.id });
server.create('allocation', { jobId: job3.id, nodeId: c.id });
server.create('allocation', { jobId: job3.id, nodeId: c.id });
});
// Job with client not scheduled.
const jobNotScheduled = server.create('job', {
status: 'running',
datacenters: ['dc1'],
type,
resourceSpec: ['M: 256, C: 500'],
createAllocations: false,
});
clients.forEach((c, i) => {
if (i > clients.length - 3) return;
server.create('allocation', { jobId: jobNotScheduled.id, nodeId: c.id });
});
});
}

View File

@@ -0,0 +1,205 @@
import { currentURL } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import Clients from 'nomad-ui/tests/pages/jobs/job/clients';
let job;
let clients;
const makeSearchableClients = (server, job) => {
Array(10)
.fill(null)
.map((_, index) => {
const node = server.create('node', {
id: index < 5 ? `ffffff-dddddd-${index}` : `111111-222222-${index}`,
datacenter: 'dc1',
status: 'ready',
});
server.create('allocation', { jobId: job.id, nodeId: node.id });
});
};
module('Acceptance | job clients', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function() {
clients = server.createList('node', 12, {
datacenter: 'dc1',
status: 'ready',
});
// Job with 1 task group.
job = server.create('job', {
status: 'running',
datacenters: ['dc1'],
type: 'sysbatch',
resourceSpec: ['M: 256, C: 500'],
createAllocations: false,
});
clients.forEach(c => {
server.create('allocation', { jobId: job.id, nodeId: c.id });
});
// Create clients without allocations to have some 'not scheduled' job status.
clients = clients.concat(
server.createList('node', 3, {
datacenter: 'dc1',
status: 'ready',
})
);
});
test('it passes an accessibility audit', async function(assert) {
await Clients.visit({ id: job.id });
await a11yAudit(assert);
});
test('lists all clients for the job', async function(assert) {
await Clients.visit({ id: job.id });
assert.equal(Clients.clients.length, 15, 'Clients are shown in a table');
const clientIDs = clients.sortBy('id').map(c => c.id);
const clientsInTable = Clients.clients.map(c => c.id).sort();
assert.deepEqual(clientsInTable, clientIDs);
assert.equal(document.title, `Job ${job.name} clients - Nomad`);
});
test('dates have tooltip', async function(assert) {
await Clients.visit({ id: job.id });
Clients.clients.forEach((clientRow, index) => {
const jobStatus = Clients.clientFor(clientRow.id).status;
['createTime', 'modifyTime'].forEach(col => {
if (jobStatus === 'not scheduled') {
assert.equal(clientRow[col].text, '-', `row ${index} doesn't have ${col} tooltip`);
return;
}
const hasTooltip = clientRow[col].tooltip.isPresent;
const tooltipText = clientRow[col].tooltip.text;
assert.true(hasTooltip, `row ${index} has ${col} tooltip`);
assert.ok(tooltipText, `row ${index} has ${col} tooltip content ${tooltipText}`);
});
});
});
test('clients table is sortable', async function(assert) {
await Clients.visit({ id: job.id });
await Clients.sortBy('node.name');
assert.equal(
currentURL(),
`/jobs/${job.id}/clients?desc=true&sort=node.name`,
'the URL persists the sort parameter'
);
const sortedClients = clients.sortBy('name').reverse();
Clients.clients.forEach((client, index) => {
const shortId = sortedClients[index].id.split('-')[0];
assert.equal(
client.shortId,
shortId,
`Client ${index} is ${shortId} with name ${sortedClients[index].name}`
);
});
});
test('clients table is searchable', async function(assert) {
makeSearchableClients(server, job);
await Clients.visit({ id: job.id });
await Clients.search('ffffff');
assert.equal(Clients.clients.length, 5, 'List is filtered by search term');
});
test('when a search yields no results, the search box remains', async function(assert) {
makeSearchableClients(server, job);
await Clients.visit({ id: job.id });
await Clients.search('^nothing will ever match this long regex$');
assert.equal(
Clients.emptyState.headline,
'No Matches',
'List is empty and the empty state is about search'
);
assert.ok(Clients.hasSearchBox, 'Search box is still shown');
});
test('when the job for the clients is not found, an error message is shown, but the URL persists', async function(assert) {
await Clients.visit({ id: 'not-a-real-job' });
assert.equal(
server.pretender.handledRequests
.filter(request => !request.url.includes('policy'))
.findBy('status', 404).url,
'/v1/job/not-a-real-job',
'A request to the nonexistent job is made'
);
assert.equal(currentURL(), '/jobs/not-a-real-job/clients', 'The URL persists');
assert.ok(Clients.error.isPresent, 'Error message is shown');
assert.equal(Clients.error.title, 'Not Found', 'Error message is for 404');
});
test('clicking row goes to client details', async function(assert) {
const client = clients[0];
await Clients.visit({ id: job.id });
await Clients.clientFor(client.id).click();
assert.equal(currentURL(), `/clients/${client.id}`);
await Clients.visit({ id: job.id });
await Clients.clientFor(client.id).visit();
assert.equal(currentURL(), `/clients/${client.id}`);
await Clients.visit({ id: job.id });
await Clients.clientFor(client.id).visitRow();
assert.equal(currentURL(), `/clients/${client.id}`);
});
testFacet('Job Status', {
facet: Clients.facets.jobStatus,
paramName: 'jobStatus',
expectedOptions: [
'Queued',
'Not Scheduled',
'Starting',
'Running',
'Complete',
'Degraded',
'Failed',
'Lost',
],
async beforeEach() {
await Clients.visit({ id: job.id });
},
});
function testFacet(label, { facet, paramName, beforeEach, expectedOptions }) {
test(`the ${label} facet has the correct options`, async function(assert) {
await beforeEach();
await facet.toggle();
let expectation;
if (typeof expectedOptions === 'function') {
expectation = expectedOptions();
} else {
expectation = expectedOptions;
}
assert.deepEqual(
facet.options.map(option => option.label.trim()),
expectation,
`Options for facet ${paramName} are as expected`
);
});
// TODO: add facet tests for actual list filtering
}
});

View File

@@ -5,15 +5,85 @@ import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import moment from 'moment';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import moduleForJob from 'nomad-ui/tests/helpers/module-for-job';
import moduleForJob, { moduleForJobWithClientStatus } from 'nomad-ui/tests/helpers/module-for-job';
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
moduleForJob('Acceptance | job detail (batch)', 'allocations', () =>
server.create('job', { type: 'batch', shallow: true })
);
moduleForJob('Acceptance | job detail (system)', 'allocations', () =>
server.create('job', { type: 'system', shallow: true })
);
moduleForJobWithClientStatus('Acceptance | job detail with client status (system)', () =>
server.create('job', {
status: 'running',
datacenters: ['dc1'],
type: 'system',
createAllocations: false,
})
);
moduleForJob('Acceptance | job detail (sysbatch)', 'allocations', () =>
server.create('job', { type: 'sysbatch', shallow: true })
);
moduleForJobWithClientStatus('Acceptance | job detail with client status (sysbatch)', () =>
server.create('job', {
status: 'running',
datacenters: ['dc1'],
type: 'sysbatch',
createAllocations: false,
})
);
moduleForJobWithClientStatus(
'Acceptance | job detail with client status (sysbatch with namespace)',
() => {
const namespace = server.create('namespace', { id: 'test' });
return server.create('job', {
status: 'running',
datacenters: ['dc1'],
type: 'sysbatch',
namespaceId: namespace.name,
createAllocations: false,
});
}
);
moduleForJob('Acceptance | job detail (sysbatch child)', 'allocations', () => {
const parent = server.create('job', 'periodicSysbatch', {
childrenCount: 1,
shallow: true,
datacenters: ['dc1'],
});
return server.db.jobs.where({ parentId: parent.id })[0];
});
moduleForJobWithClientStatus('Acceptance | job detail with client status (sysbatch child)', () => {
const parent = server.create('job', 'periodicSysbatch', {
childrenCount: 1,
shallow: true,
datacenters: ['dc1'],
});
return server.db.jobs.where({ parentId: parent.id })[0];
});
moduleForJobWithClientStatus(
'Acceptance | job detail with client status (sysbatch child with namespace)',
() => {
const namespace = server.create('namespace', { id: 'test' });
const parent = server.create('job', 'periodicSysbatch', {
childrenCount: 1,
shallow: true,
namespaceId: namespace.name,
datacenters: ['dc1'],
});
return server.db.jobs.where({ parentId: parent.id })[0];
}
);
moduleForJob(
'Acceptance | job detail (periodic)',
'children',

View File

@@ -217,7 +217,7 @@ module('Acceptance | jobs list', function(hooks) {
testFacet('Type', {
facet: JobsList.facets.type,
paramName: 'type',
expectedOptions: ['Batch', 'Parameterized', 'Periodic', 'Service', 'System'],
expectedOptions: ['Batch', 'Parameterized', 'Periodic', 'Service', 'System', 'System Batch'],
async beforeEach() {
server.createList('job', 2, { createAllocations: false, type: 'batch' });
server.createList('job', 2, {

View File

@@ -119,10 +119,95 @@ export default function moduleForJob(title, context, jobFactory, additionalTests
});
}
// eslint-disable-next-line ember/no-test-module-for
export function moduleForJobWithClientStatus(title, jobFactory, additionalTests) {
let job;
module(title, function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function() {
const clients = server.createList('node', 3, {
datacenter: 'dc1',
status: 'ready',
});
job = jobFactory();
clients.forEach(c => {
server.create('allocation', { jobId: job.id, nodeId: c.id });
});
if (!job.namespace || job.namespace === 'default') {
await JobDetail.visit({ id: job.id });
} else {
await JobDetail.visit({ id: job.id, namespace: job.namespace });
}
});
test('the subnav links to clients', async function(assert) {
await JobDetail.tabFor('clients').visit();
assert.equal(
currentURL(),
urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}/clients`, job.namespace)
);
});
test('job status summary is shown in the overview', async function(assert) {
assert.ok(
JobDetail.jobClientStatusSummary.isPresent,
'Summary bar is displayed in the Job Status in Client summary section'
);
});
test('clicking legend item navigates to a pre-filtered clients table', async function(assert) {
const legendItem = JobDetail.jobClientStatusSummary.legend.clickableItems[0];
const status = legendItem.label;
await legendItem.click();
const encodedStatus = encodeURIComponent(JSON.stringify([status]));
const expectedURL = new URL(
urlWithNamespace(`/jobs/${job.name}/clients?status=${encodedStatus}`, job.namespace),
window.location
);
const gotURL = new URL(currentURL(), window.location);
assert.deepEqual(gotURL.path, expectedURL.path);
assert.deepEqual(gotURL.searchParams, expectedURL.searchParams);
});
test('clicking in a slice takes you to a pre-filtered clients table', async function(assert) {
const slice = JobDetail.jobClientStatusSummary.slices[0];
const status = slice.label;
await slice.click();
const encodedStatus = encodeURIComponent(JSON.stringify([status]));
const expectedURL = new URL(
urlWithNamespace(`/jobs/${job.name}/clients?status=${encodedStatus}`, job.namespace),
window.location
);
const gotURL = new URL(currentURL(), window.location);
assert.deepEqual(gotURL.pathname, expectedURL.pathname);
// Sort and compare URL query params.
gotURL.searchParams.sort();
expectedURL.searchParams.sort();
assert.equal(gotURL.searchParams.toString(), expectedURL.searchParams.toString());
});
for (var testName in additionalTests) {
test(testName, async function(assert) {
await additionalTests[testName].call(this, job, assert);
});
}
});
}
function urlWithNamespace(url, namespace) {
if (!namespace || namespace === 'default') {
return url;
}
return `${url}?namespace=${namespace}`;
const parts = url.split('?');
const params = new URLSearchParams(parts[1]);
params.set('namespace', namespace);
return `${parts[0]}?${params.toString()}`;
}

View File

@@ -0,0 +1,77 @@
import { module, test } from 'qunit';
import { create } from 'ember-cli-page-object';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import sinon from 'sinon';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import jobClientStatusBar from 'nomad-ui/tests/pages/components/job-client-status-bar';
const JobClientStatusBar = create(jobClientStatusBar());
module('Integration | Component | job-client-status-bar', function(hooks) {
setupRenderingTest(hooks);
const commonProperties = () => ({
onSliceClick: sinon.spy(),
job: {
namespace: {
get: () => 'my-namespace',
},
},
jobClientStatus: {
byStatus: {
queued: [],
starting: ['someNodeId'],
running: [],
complete: [],
degraded: [],
failed: [],
lost: [],
notScheduled: [],
},
},
isNarrow: true,
});
const commonTemplate = hbs`
<JobClientStatusBar
@onSliceClick={{onSliceClick}}
@job={{job}}
@jobClientStatus={{jobClientStatus}}
@isNarrow={{isNarrow}}
/>`;
test('it renders', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await render(commonTemplate);
assert.ok(JobClientStatusBar.isPresent, 'Client Status Bar is rendered');
await componentA11yAudit(this.element, assert);
});
test('it fires the onBarClick handler method when clicking a bar in the chart', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await render(commonTemplate);
await JobClientStatusBar.slices[0].click();
assert.ok(props.onSliceClick.calledOnce);
});
test('it handles an update to client status property', async function(assert) {
const props = commonProperties();
this.setProperties(props);
await render(commonTemplate);
const newProps = {
...props,
jobClientStatus: {
...props.jobClientStatus,
byStatus: { ...props.jobClientStatus.byStatus, starting: [], running: ['someNodeId'] },
},
};
this.setProperties(newProps);
await JobClientStatusBar.visitSlice('running');
assert.ok(props.onSliceClick.calledOnce);
});
});

View File

@@ -0,0 +1,40 @@
import { attribute, collection, clickable, text } from 'ember-cli-page-object';
import { singularize } from 'ember-inflector';
export default function(selector = '[data-test-client]', propKey = 'clients') {
const lookupKey = `${singularize(propKey)}For`;
// Remove the bracket notation
const attr = selector.substring(1, selector.length - 1);
return {
[propKey]: collection(selector, {
id: attribute(attr),
shortId: text('[data-test-short-id]'),
name: text('[data-test-name]'),
status: text('[data-test-job-status]'),
createTime: {
scope: '[data-test-create-time]',
tooltip: {
scope: '.tooltip',
text: attribute('aria-label'),
},
},
modifyTime: {
scope: '[data-test-modify-time]',
tooltip: {
scope: '.tooltip',
text: attribute('aria-label'),
},
},
visit: clickable('[data-test-short-id] a'),
visitRow: clickable(),
}),
[lookupKey]: function(id) {
return this[propKey].toArray().find(client => client.id === id);
},
};
}

View File

@@ -0,0 +1,37 @@
import { attribute, clickable, collection } from 'ember-cli-page-object';
export default scope => ({
scope,
slices: collection('svg .bars g', {
label: attribute('data-test-slice-label'),
click: clickable(),
}),
legend: {
scope: '.legend',
items: collection('li', {
label: attribute('data-test-legent-label'),
}),
clickableItems: collection('li.is-clickable', {
label: attribute('data-test-legent-label'),
click: clickable('a'),
}),
},
visitSlice: async function(label) {
await this.slices
.toArray()
.findBy('label', label)
.click();
},
visitLegend: async function(label) {
await this.legend.clickableItems
.toArray()
.findBy('label', label)
.click();
},
});

View File

@@ -13,6 +13,7 @@ import {
import allocations from 'nomad-ui/tests/pages/components/allocations';
import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
import recommendationAccordion from 'nomad-ui/tests/pages/components/recommendation-accordion';
import jobClientStatusBar from 'nomad-ui/tests/pages/components/job-client-status-bar';
export default create({
visit: visitable('/jobs/:id'),
@@ -59,6 +60,7 @@ export default create({
return this.stats.toArray().findBy('id', id);
},
jobClientStatusSummary: jobClientStatusBar('[data-test-job-client-status-bar]'),
childrenSummary: isPresent('[data-test-job-summary] [data-test-children-status-bar]'),
allocationsSummary: isPresent('[data-test-job-summary] [data-test-allocation-status-bar]'),

View File

@@ -0,0 +1,49 @@
import {
attribute,
clickable,
create,
collection,
fillable,
isPresent,
text,
visitable,
} from 'ember-cli-page-object';
import { multiFacet } from 'nomad-ui/tests/pages/components/facet';
import clients from 'nomad-ui/tests/pages/components/clients';
import error from 'nomad-ui/tests/pages/components/error';
export default create({
visit: visitable('/jobs/:id/clients'),
pageSize: 25,
hasSearchBox: isPresent('[data-test-clients-search]'),
search: fillable('[data-test-clients-search] input'),
...clients(),
isEmpty: isPresent('[data-test-empty-clients-list]'),
emptyState: {
headline: text('[data-test-empty-clients-list-headline]'),
},
sortOptions: collection('[data-test-sort-by]', {
id: attribute('data-test-sort-by'),
sort: clickable(),
}),
sortBy(id) {
return this.sortOptions
.toArray()
.findBy('id', id)
.sort();
},
facets: {
jobStatus: multiFacet('[data-test-job-status-facet]'),
datacenter: multiFacet('[data-test-datacenter-facet]'),
clientClass: multiFacet('[data-test-class-facet]'),
},
error: error(),
});

View File

@@ -0,0 +1,319 @@
import { module, test } from 'qunit';
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
import EmberObject from '@ember/object';
class JobClientStatusMock extends EmberObject {
constructor(job, nodes) {
super(...arguments);
this.job = job;
this.nodes = nodes;
}
@jobClientStatus('nodes', 'job') jobClientStatus;
get(key) {
switch (key) {
case 'job':
return this.job;
case 'nodes':
return this.nodes;
}
}
}
class NodeMock {
constructor(id, datacenter) {
this.id = id;
this.datacenter = datacenter;
}
get(key) {
switch (key) {
case 'id':
return this.id;
}
}
}
module('Unit | Util | JobClientStatus', function() {
test('it handles the case where all nodes are running', async function(assert) {
const node = new NodeMock('node-1', 'dc1');
const nodes = [node];
const job = {
datacenters: ['dc1'],
status: 'running',
allocations: [{ node, clientStatus: 'running' }],
taskGroups: [{}],
};
const expected = {
byNode: {
'node-1': 'running',
},
byStatus: {
running: ['node-1'],
complete: [],
degraded: [],
failed: [],
lost: [],
notScheduled: [],
queued: [],
starting: [],
},
totalNodes: 1,
};
const mock = new JobClientStatusMock(job, nodes);
let result = mock.jobClientStatus;
assert.deepEqual(result, expected);
});
test('it handles the degraded case where a node has a failing allocation', async function(assert) {
const node = new NodeMock('node-2', 'dc1');
const nodes = [node];
const job = {
datacenters: ['dc1'],
status: 'running',
allocations: [
{ node, clientStatus: 'running' },
{ node, clientStatus: 'failed' },
{ node, clientStatus: 'running' },
],
taskGroups: [{}, {}, {}],
};
const expected = {
byNode: {
'node-2': 'degraded',
},
byStatus: {
running: [],
complete: [],
degraded: ['node-2'],
failed: [],
lost: [],
notScheduled: [],
queued: [],
starting: [],
},
totalNodes: 1,
};
const mock = new JobClientStatusMock(job, nodes);
let result = mock.jobClientStatus;
assert.deepEqual(result, expected);
});
test('it handles the case where a node has all lost allocations', async function(assert) {
const node = new NodeMock('node-1', 'dc1');
const nodes = [node];
const job = {
datacenters: ['dc1'],
status: 'running',
allocations: [
{ node, clientStatus: 'lost' },
{ node, clientStatus: 'lost' },
{ node, clientStatus: 'lost' },
],
taskGroups: [{}, {}, {}],
};
const expected = {
byNode: {
'node-1': 'lost',
},
byStatus: {
running: [],
complete: [],
degraded: [],
failed: [],
lost: ['node-1'],
notScheduled: [],
queued: [],
starting: [],
},
totalNodes: 1,
};
const mock = new JobClientStatusMock(job, nodes);
let result = mock.jobClientStatus;
assert.deepEqual(result, expected);
});
test('it handles the case where a node has all failed allocations', async function(assert) {
const node = new NodeMock('node-1', 'dc1');
const nodes = [node];
const job = {
datacenters: ['dc1'],
status: 'running',
allocations: [
{ node, clientStatus: 'failed' },
{ node, clientStatus: 'failed' },
{ node, clientStatus: 'failed' },
],
taskGroups: [{}, {}, {}],
};
const expected = {
byNode: {
'node-1': 'failed',
},
byStatus: {
running: [],
complete: [],
degraded: [],
failed: ['node-1'],
lost: [],
notScheduled: [],
queued: [],
starting: [],
},
totalNodes: 1,
};
const mock = new JobClientStatusMock(job, nodes);
let result = mock.jobClientStatus;
assert.deepEqual(result, expected);
});
test('it handles the degraded case where the expected number of allocations doesnt match the actual number of allocations', async function(assert) {
const node = new NodeMock('node-1', 'dc1');
const nodes = [node];
const job = {
datacenters: ['dc1'],
status: 'running',
allocations: [
{ node, clientStatus: 'running' },
{ node, clientStatus: 'running' },
{ node, clientStatus: 'running' },
],
taskGroups: [{}, {}, {}, {}],
};
const expected = {
byNode: {
'node-1': 'degraded',
},
byStatus: {
running: [],
complete: [],
degraded: ['node-1'],
failed: [],
lost: [],
notScheduled: [],
queued: [],
starting: [],
},
totalNodes: 1,
};
const mock = new JobClientStatusMock(job, nodes);
let result = mock.jobClientStatus;
assert.deepEqual(result, expected);
});
test('it handles the not scheduled case where a node has no allocations', async function(assert) {
const node = new NodeMock('node-1', 'dc1');
const nodes = [node];
const job = {
datacenters: ['dc1'],
status: 'running',
allocations: [],
taskGroups: [],
};
const expected = {
byNode: {
'node-1': 'notScheduled',
},
byStatus: {
running: [],
complete: [],
degraded: [],
failed: [],
lost: [],
notScheduled: ['node-1'],
queued: [],
starting: [],
},
totalNodes: 1,
};
const mock = new JobClientStatusMock(job, nodes);
let result = mock.jobClientStatus;
assert.deepEqual(result, expected);
});
test('it handles the queued case where the job is pending', async function(assert) {
const node = new NodeMock('node-1', 'dc1');
const nodes = [node];
const job = {
datacenters: ['dc1'],
status: 'pending',
allocations: [
{ node, clientStatus: 'starting' },
{ node, clientStatus: 'starting' },
{ node, clientStatus: 'starting' },
],
taskGroups: [{}, {}, {}, {}],
};
const expected = {
byNode: {
'node-1': 'queued',
},
byStatus: {
running: [],
complete: [],
degraded: [],
failed: [],
lost: [],
notScheduled: [],
queued: ['node-1'],
starting: [],
},
totalNodes: 1,
};
const mock = new JobClientStatusMock(job, nodes);
let result = mock.jobClientStatus;
assert.deepEqual(result, expected);
});
test('it filters nodes by the datacenter of the job', async function(assert) {
const node1 = new NodeMock('node-1', 'dc1');
const node2 = new NodeMock('node-2', 'dc2');
const nodes = [node1, node2];
const job = {
datacenters: ['dc1'],
status: 'running',
allocations: [
{ node: node1, clientStatus: 'running' },
{ node: node2, clientStatus: 'failed' },
{ node: node1, clientStatus: 'running' },
],
taskGroups: [{}, {}],
};
const expected = {
byNode: {
'node-1': 'running',
},
byStatus: {
running: ['node-1'],
complete: [],
degraded: [],
failed: [],
lost: [],
notScheduled: [],
queued: [],
starting: [],
},
totalNodes: 1,
};
const mock = new JobClientStatusMock(job, nodes);
let result = mock.jobClientStatus;
assert.deepEqual(result, expected);
});
});