Fix ACL requirements for job details UI (#11672)
This commit is contained in:
parent
7e6acf0e68
commit
c7ae13a1f3
|
@ -0,0 +1,3 @@
|
|||
```release-note:bug
|
||||
ui: Fix the ACL requirements for displaying the job details page
|
||||
```
|
|
@ -7,18 +7,30 @@ import classic from 'ember-classic-decorator';
|
|||
export default class Client extends AbstractAbility {
|
||||
// Map abilities to policy options (which are coarse for nodes)
|
||||
// instead of specific behaviors.
|
||||
@or('bypassAuthorization', 'selfTokenIsManagement', 'policiesIncludeNodeRead')
|
||||
canRead;
|
||||
|
||||
@or('bypassAuthorization', 'selfTokenIsManagement', 'policiesIncludeNodeWrite')
|
||||
canWrite;
|
||||
|
||||
@computed('token.selfTokenPolicies.[]')
|
||||
get policiesIncludeNodeWrite() {
|
||||
// For each policy record, extract the Node policy
|
||||
const policies = (this.get('token.selfTokenPolicies') || [])
|
||||
.toArray()
|
||||
.map(policy => get(policy, 'rulesJSON.Node.Policy'))
|
||||
.compact();
|
||||
get policiesIncludeNodeRead() {
|
||||
return policiesIncludePermissions(this.get('token.selfTokenPolicies'), ['read', 'write']);
|
||||
}
|
||||
|
||||
// Node write is allowed if any policy allows it
|
||||
return policies.some(policy => policy === 'write');
|
||||
@computed('token.selfTokenPolicies.[]')
|
||||
get policiesIncludeNodeWrite() {
|
||||
return policiesIncludePermissions(this.get('token.selfTokenPolicies'), ['write']);
|
||||
}
|
||||
}
|
||||
|
||||
function policiesIncludePermissions(policies = [], permissions = []) {
|
||||
// For each policy record, extract the Node policy
|
||||
const nodePolicies = policies
|
||||
.toArray()
|
||||
.map(policy => get(policy, 'rulesJSON.Node.Policy'))
|
||||
.compact();
|
||||
|
||||
// Check for requested permissions
|
||||
return nodePolicies.some(policy => permissions.includes(policy));
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import classic from 'ember-classic-decorator';
|
|||
|
||||
@classic
|
||||
export default class Abstract extends Component {
|
||||
@service can;
|
||||
@service system;
|
||||
|
||||
job = null;
|
||||
|
@ -20,6 +21,10 @@ export default class Abstract extends Component {
|
|||
// Set to a { title, description } to surface an error
|
||||
errorMessage = null;
|
||||
|
||||
get shouldDisplayClientInformation() {
|
||||
return this.can.can('read client') && this.job.hasClientStatus;
|
||||
}
|
||||
|
||||
@action
|
||||
clearErrorMessage() {
|
||||
this.set('errorMessage', null);
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
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() {
|
||||
|
@ -20,10 +17,4 @@ export default class ParameterizedChild extends PeriodicChildJobPage {
|
|||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
@jobClientStatus('nodes', 'job') jobClientStatus;
|
||||
|
||||
get nodes() {
|
||||
return this.store.peekAll('node');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,20 +2,26 @@ import Component from '@ember/component';
|
|||
import { action, computed } from '@ember/object';
|
||||
import { classNames } from '@ember-decorators/component';
|
||||
import classic from 'ember-classic-decorator';
|
||||
import jobClientStatus from 'nomad-ui/utils/properties/job-client-status';
|
||||
|
||||
@classic
|
||||
@classNames('boxed-section')
|
||||
export default class JobClientStatusSummary extends Component {
|
||||
job = null;
|
||||
jobClientStatus = null;
|
||||
nodes = null;
|
||||
forceCollapsed = false;
|
||||
gotoClients() {}
|
||||
|
||||
@computed
|
||||
@computed('forceCollapsed')
|
||||
get isExpanded() {
|
||||
if (this.forceCollapsed) return false;
|
||||
|
||||
const storageValue = window.localStorage.nomadExpandJobClientStatusSummary;
|
||||
return storageValue != null ? JSON.parse(storageValue) : true;
|
||||
}
|
||||
|
||||
@jobClientStatus('nodes', 'job') jobClientStatus;
|
||||
|
||||
@action
|
||||
onSliceClick(ev, slice) {
|
||||
this.gotoClients([slice.className.camelize()]);
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
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;
|
||||
|
@ -25,10 +21,4 @@ export default class PeriodicChild extends AbstractJobPage {
|
|||
},
|
||||
];
|
||||
}
|
||||
|
||||
@jobClientStatus('nodes', 'job') jobClientStatus;
|
||||
|
||||
get nodes() {
|
||||
return this.store.peekAll('node');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,5 @@
|
|||
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');
|
||||
}
|
||||
}
|
||||
export default class Sysbatch extends AbstractJobPage {}
|
||||
|
|
|
@ -1,15 +1,5 @@
|
|||
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 {
|
||||
@service store;
|
||||
|
||||
@jobClientStatus('nodes', 'job') jobClientStatus;
|
||||
|
||||
get nodes() {
|
||||
return this.store.peekAll('node');
|
||||
}
|
||||
}
|
||||
export default class System extends AbstractJobPage {}
|
||||
|
|
|
@ -9,8 +9,10 @@ export default class AccordionHead extends Component {
|
|||
'data-test-accordion-head' = true;
|
||||
|
||||
buttonLabel = 'toggle';
|
||||
tooltip = '';
|
||||
isOpen = false;
|
||||
isExpandable = true;
|
||||
isDisabled = false;
|
||||
item = null;
|
||||
|
||||
onClose() {}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { inject as service } from '@ember/service';
|
||||
import { alias } from '@ember/object/computed';
|
||||
import { computed } from '@ember/object';
|
||||
import Controller from '@ember/controller';
|
||||
import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
|
||||
import { action } from '@ember/object';
|
||||
|
@ -23,7 +23,15 @@ export default class IndexController extends Controller.extend(WithNamespaceRese
|
|||
|
||||
currentPage = 1;
|
||||
|
||||
@alias('model') job;
|
||||
@computed('model.job')
|
||||
get job() {
|
||||
return this.model.job;
|
||||
}
|
||||
|
||||
@computed('model.nodes.[]')
|
||||
get nodes() {
|
||||
return this.model.nodes;
|
||||
}
|
||||
|
||||
sortProperty = 'name';
|
||||
sortDescending = false;
|
||||
|
|
|
@ -30,6 +30,16 @@ export default class Allocation extends Model {
|
|||
@fragment('resources') allocatedResources;
|
||||
@attr('number') jobVersion;
|
||||
|
||||
// Store basic node information returned by the API to avoid the need for
|
||||
// node:read ACL permission.
|
||||
@attr('string') nodeName;
|
||||
@computed
|
||||
get shortNodeId() {
|
||||
return this.belongsTo('node')
|
||||
.id()
|
||||
.split('-')[0];
|
||||
}
|
||||
|
||||
@attr('number') modifyIndex;
|
||||
@attr('date') modifyTime;
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { collect } from '@ember/object/computed';
|
||||
import {
|
||||
watchRecord,
|
||||
|
@ -9,34 +10,49 @@ import {
|
|||
import WithWatchers from 'nomad-ui/mixins/with-watchers';
|
||||
|
||||
export default class IndexRoute extends Route.extend(WithWatchers) {
|
||||
@service can;
|
||||
@service store;
|
||||
|
||||
async model() {
|
||||
// Optimizing future node look ups by preemptively loading everything
|
||||
await this.store.findAll('node');
|
||||
return this.modelFor('jobs.job');
|
||||
const job = this.modelFor('jobs.job');
|
||||
if (!job) {
|
||||
return { job, nodes: [] };
|
||||
}
|
||||
|
||||
// Optimizing future node look ups by preemptively loading all nodes if
|
||||
// necessary and allowed.
|
||||
if (this.can.can('read client') && job.get('hasClientStatus')) {
|
||||
await this.store.findAll('node');
|
||||
}
|
||||
const nodes = this.store.peekAll('node');
|
||||
return { job, nodes };
|
||||
}
|
||||
|
||||
startWatchers(controller, model) {
|
||||
if (!model) {
|
||||
if (!model.job) {
|
||||
return;
|
||||
}
|
||||
controller.set('watchers', {
|
||||
model: this.watch.perform(model),
|
||||
summary: this.watchSummary.perform(model.get('summary')),
|
||||
allocations: this.watchAllocations.perform(model),
|
||||
evaluations: this.watchEvaluations.perform(model),
|
||||
model: this.watch.perform(model.job),
|
||||
summary: this.watchSummary.perform(model.job.get('summary')),
|
||||
allocations: this.watchAllocations.perform(model.job),
|
||||
evaluations: this.watchEvaluations.perform(model.job),
|
||||
latestDeployment:
|
||||
model.get('supportsDeployments') && this.watchLatestDeployment.perform(model),
|
||||
model.job.get('supportsDeployments') && this.watchLatestDeployment.perform(model.job),
|
||||
list:
|
||||
model.get('hasChildren') &&
|
||||
this.watchAllJobs.perform({ namespace: model.namespace.get('name') }),
|
||||
nodes: model.get('hasClientStatus') && this.watchNodes.perform(),
|
||||
model.job.get('hasChildren') &&
|
||||
this.watchAllJobs.perform({ namespace: model.job.namespace.get('name') }),
|
||||
nodes:
|
||||
this.can.can('read client') &&
|
||||
model.job.get('hasClientStatus') &&
|
||||
this.watchNodes.perform(),
|
||||
});
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
// Parameterized and periodic detail pages, which list children jobs,
|
||||
// should sort by submit time.
|
||||
if (model && ['periodic', 'parameterized'].includes(model.templateType)) {
|
||||
if (model.job && ['periodic', 'parameterized'].includes(model.job.templateType)) {
|
||||
controller.setProperties({
|
||||
sortProperty: 'submitTime',
|
||||
sortDescending: true,
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<td data-test-indicators class="is-narrow">
|
||||
{{#if this.allocation.unhealthyDrivers.length}}
|
||||
<span data-test-icon="unhealthy-driver" class="tooltip text-center" role="tooltip" aria-label="Allocation depends on unhealthy drivers">
|
||||
{{x-icon "alert-triangle" class="is-warning"}}
|
||||
</span>
|
||||
{{#if (can "read client")}}
|
||||
{{#if this.allocation.unhealthyDrivers.length}}
|
||||
<span data-test-icon="unhealthy-driver" class="tooltip text-center" role="tooltip" aria-label="Allocation depends on unhealthy drivers">
|
||||
{{x-icon "alert-triangle" class="is-warning"}}
|
||||
</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if this.allocation.nextAllocation}}
|
||||
<span data-test-icon="reschedule" class="tooltip text-center" role="tooltip" aria-label="Allocation was rescheduled">
|
||||
|
@ -38,20 +40,28 @@
|
|||
</td>
|
||||
{{#if (eq this.context "volume")}}
|
||||
<td data-test-client>
|
||||
<Tooltip @text={{this.allocation.node.name}}>
|
||||
<LinkTo @route="clients.client" @model={{this.allocation.node}}>
|
||||
{{this.allocation.node.shortId}}
|
||||
</LinkTo>
|
||||
<Tooltip @text={{this.allocation.nodeName}}>
|
||||
{{#if (can "read client")}}
|
||||
<LinkTo @route="clients.client" @model={{this.allocation.node}}>
|
||||
{{this.allocation.shortNodeId}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
{{this.allocation.shortNodeId}}
|
||||
{{/if}}
|
||||
</Tooltip>
|
||||
</td>
|
||||
{{/if}}
|
||||
{{#if (or (eq this.context "taskGroup") (eq this.context "job"))}}
|
||||
<td data-test-job-version>{{this.allocation.jobVersion}}</td>
|
||||
<td data-test-client>
|
||||
<Tooltip @text={{this.allocation.node.name}}>
|
||||
<LinkTo @route="clients.client" @model={{this.allocation.node}}>
|
||||
{{this.allocation.node.shortId}}
|
||||
</LinkTo>
|
||||
<Tooltip @text={{this.allocation.nodeName}}>
|
||||
{{#if (can "read client")}}
|
||||
<LinkTo @route="clients.client" @model={{this.allocation.node}}>
|
||||
{{this.allocation.shortNodeId}}
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
{{this.allocation.shortNodeId}}
|
||||
{{/if}}
|
||||
</Tooltip>
|
||||
</td>
|
||||
{{else if (or (eq this.context "node") (eq this.context "volume"))}}
|
||||
|
|
|
@ -16,13 +16,13 @@
|
|||
|
||||
{{#if this.job.hasClientStatus}}
|
||||
<JobPage::Parts::JobClientStatusSummary
|
||||
@gotoClients={{this.gotoClients}}
|
||||
@job={{this.job}}
|
||||
@jobClientStatus={{this.jobClientStatus}}
|
||||
/>
|
||||
@nodes={{this.nodes}}
|
||||
@forceCollapsed={{not this.shouldDisplayClientInformation}}
|
||||
@gotoClients={{this.gotoClients}} />
|
||||
{{/if}}
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.job.hasClientStatus}} />
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.shouldDisplayClientInformation}} />
|
||||
|
||||
<JobPage::Parts::PlacementFailures @job={{this.job}} />
|
||||
|
||||
|
|
|
@ -1,22 +1,28 @@
|
|||
<ListAccordion
|
||||
data-test-job-summary
|
||||
data-test-job-client-summary
|
||||
@source={{array this.job}}
|
||||
@key="id"
|
||||
@startExpanded={{this.isExpanded}}
|
||||
@onToggle={{action this.persist}} as |a|
|
||||
>
|
||||
<a.head @buttonLabel={{if a.isOpen "collapse" "expand"}}>
|
||||
<a.head
|
||||
@buttonLabel={{if a.isOpen "collapse" "expand"}}
|
||||
@tooltip={{if (cannot "read client") "You don’t have permission to read clients"}}
|
||||
@isDisabled={{cannot "read client"}}
|
||||
>
|
||||
<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>
|
||||
{{#if this.jobClientStatus}}
|
||||
<span class="badge {{if a.isOpen "is-white" "is-light"}}">
|
||||
{{this.jobClientStatus.totalNodes}}
|
||||
</span>
|
||||
{{/if}}
|
||||
<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}}
|
||||
{{#if (and this.jobClientStatus (not a.isOpen))}}
|
||||
<div class="column">
|
||||
<div class="inline-chart bumper-left">
|
||||
<JobClientStatusBar
|
||||
|
@ -27,29 +33,31 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
</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}}>
|
||||
{{#if this.jobClientStatus}}
|
||||
<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}} />
|
||||
</LinkTo>
|
||||
{{else}}
|
||||
<JobPage::Parts::SummaryLegendItem @datum={{datum}} @index={{index}} />
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
</JobClientStatusBar>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ol>
|
||||
</JobClientStatusBar>
|
||||
{{/if}}
|
||||
</a.body>
|
||||
</ListAccordion>
|
||||
|
|
|
@ -16,13 +16,13 @@
|
|||
|
||||
{{#if this.job.hasClientStatus}}
|
||||
<JobPage::Parts::JobClientStatusSummary
|
||||
@gotoClients={{this.gotoClients}}
|
||||
@job={{this.job}}
|
||||
@jobClientStatus={{this.jobClientStatus}}
|
||||
/>
|
||||
@nodes={{this.nodes}}
|
||||
@forceCollapsed={{not this.shouldDisplayClientInformation}}
|
||||
@gotoClients={{this.gotoClients}} />
|
||||
{{/if}}
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.job.hasClientStatus}} />
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.shouldDisplayClientInformation}} />
|
||||
|
||||
<JobPage::Parts::PlacementFailures @job={{this.job}} />
|
||||
|
||||
|
|
|
@ -7,10 +7,11 @@
|
|||
|
||||
<JobPage::Parts::JobClientStatusSummary
|
||||
@job={{this.job}}
|
||||
@jobClientStatus={{this.jobClientStatus}}
|
||||
@nodes={{this.nodes}}
|
||||
@forceCollapsed={{not this.shouldDisplayClientInformation}}
|
||||
@gotoClients={{this.gotoClients}} />
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed="true" />
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.shouldDisplayClientInformation}} />
|
||||
|
||||
<JobPage::Parts::PlacementFailures @job={{this.job}} />
|
||||
|
||||
|
|
|
@ -13,10 +13,11 @@
|
|||
|
||||
<JobPage::Parts::JobClientStatusSummary
|
||||
@job={{this.job}}
|
||||
@jobClientStatus={{this.jobClientStatus}}
|
||||
@nodes={{this.nodes}}
|
||||
@forceCollapsed={{not this.shouldDisplayClientInformation}}
|
||||
@gotoClients={{this.gotoClients}} />
|
||||
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed="true" />
|
||||
<JobPage::Parts::Summary @job={{this.job}} @forceCollapsed={{this.shouldDisplayClientInformation}} />
|
||||
|
||||
<JobPage::Parts::PlacementFailures @job={{this.job}} />
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
{{/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))}}
|
||||
{{#if (and (can "read client") (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}}
|
||||
</ul>
|
||||
|
|
|
@ -3,7 +3,9 @@
|
|||
</div>
|
||||
<button
|
||||
data-test-accordion-toggle
|
||||
class="button is-light is-compact pull-right accordion-toggle {{unless this.isExpandable "is-invisible"}}"
|
||||
class="button is-light is-compact pull-right accordion-toggle {{unless this.isExpandable "is-invisible"}} {{if this.tooltip "tooltip multiline"}}"
|
||||
disabled={{if this.isDisabled "disabled"}}
|
||||
aria-label={{this.tooltip}}
|
||||
onclick={{action (if this.isOpen this.onClose this.onOpen) this.item}}
|
||||
type="button">
|
||||
{{this.buttonLabel}}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
<td class="is-narrow">
|
||||
{{#unless this.task.driverStatus.healthy}}
|
||||
<span data-test-icon="unhealthy-driver" class="tooltip text-center" aria-label="{{this.task.driver}} is unhealthy">
|
||||
{{x-icon "alert-triangle" class="is-warning"}}
|
||||
</span>
|
||||
{{/unless}}
|
||||
{{#if (can "read client")}}
|
||||
{{#unless this.task.driverStatus.healthy}}
|
||||
<span data-test-icon="unhealthy-driver" class="tooltip text-center" aria-label="{{this.task.driver}} is unhealthy">
|
||||
{{x-icon "alert-triangle" class="is-warning"}}
|
||||
</span>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-test-name class="nowrap">
|
||||
<LinkTo @route="allocations.allocation.task" @models={{array this.task.allocation this.task}} class="is-primary">
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{{page-title "Job " this.model.name}}
|
||||
{{component (concat "job-page/" this.model.templateType)
|
||||
job=this.model
|
||||
{{page-title "Job " this.job.name}}
|
||||
{{component (concat "job-page/" this.job.templateType)
|
||||
job=this.job
|
||||
nodes=this.nodes
|
||||
sortProperty=this.sortProperty
|
||||
sortDescending=this.sortDescending
|
||||
currentPage=this.currentPage
|
||||
|
|
|
@ -35,7 +35,7 @@ export default function jobClientStatus(nodesKey, jobKey) {
|
|||
// 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');
|
||||
const nodeId = a.belongsTo('node').id();
|
||||
if (!allocsByNodeID[nodeId]) {
|
||||
allocsByNodeID[nodeId] = [];
|
||||
}
|
||||
|
|
|
@ -175,6 +175,7 @@ export default Factory.extend({
|
|||
namespace,
|
||||
jobId: job.id,
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
taskStateIds: [],
|
||||
taskResourceIds: [],
|
||||
taskGroup: taskGroup.name,
|
||||
|
|
|
@ -6,6 +6,7 @@ import { setupApplicationTest } from 'ember-qunit';
|
|||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
|
||||
import Allocation from 'nomad-ui/tests/pages/allocations/detail';
|
||||
import Tokens from 'nomad-ui/tests/pages/settings/tokens';
|
||||
import moment from 'moment';
|
||||
import formatHost from 'nomad-ui/utils/format-host';
|
||||
|
||||
|
@ -45,6 +46,8 @@ module('Acceptance | allocation detail', function(hooks) {
|
|||
driver: 'docker',
|
||||
});
|
||||
|
||||
window.localStorage.clear();
|
||||
|
||||
await Allocation.visit({ id: allocation.id });
|
||||
});
|
||||
|
||||
|
@ -177,6 +180,26 @@ module('Acceptance | allocation detail', function(hooks) {
|
|||
});
|
||||
|
||||
test('tasks with an unhealthy driver have a warning icon', async function(assert) {
|
||||
// Driver health status require node:read permission.
|
||||
const policy = server.create('policy', {
|
||||
id: 'node-read',
|
||||
name: 'node-read',
|
||||
rulesJSON: {
|
||||
Node: {
|
||||
Policy: 'read',
|
||||
},
|
||||
},
|
||||
});
|
||||
const clientToken = server.create('token', { type: 'client' });
|
||||
clientToken.policyIds = [policy.id];
|
||||
clientToken.save();
|
||||
|
||||
// Since the page is already visited in the beforeEach hook, setting the
|
||||
// localStorage directly is not enough.
|
||||
await Tokens.visit();
|
||||
await Tokens.secret(clientToken.secretId).submit();
|
||||
await Allocation.visit({ id: allocation.id });
|
||||
|
||||
assert.ok(Allocation.firstUnhealthyTask().hasUnhealthyDriver, 'Warning is shown');
|
||||
});
|
||||
|
||||
|
@ -271,7 +294,9 @@ module('Acceptance | allocation detail', function(hooks) {
|
|||
await Allocation.stop.confirm();
|
||||
|
||||
assert.equal(
|
||||
server.pretender.handledRequests.reject(request => request.url.includes('fuzzy')).findBy('method', 'POST').url,
|
||||
server.pretender.handledRequests
|
||||
.reject(request => request.url.includes('fuzzy'))
|
||||
.findBy('method', 'POST').url,
|
||||
`/v1/allocation/${allocation.id}/stop`,
|
||||
'Stop request is made for the allocation'
|
||||
);
|
||||
|
@ -381,6 +406,7 @@ module('Acceptance | allocation detail (preemptions)', function(hooks) {
|
|||
server.create('agent');
|
||||
node = server.create('node');
|
||||
job = server.create('job', { createAllocations: false });
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
test('shows a dedicated section to the allocation that preempted this allocation', async function(assert) {
|
||||
|
@ -463,6 +489,32 @@ module('Acceptance | allocation detail (preemptions)', function(hooks) {
|
|||
server.db.nodes.find(preemption.nodeId).id.split('-')[0],
|
||||
'Node ID'
|
||||
);
|
||||
});
|
||||
|
||||
test('clicking the client ID in the preempted allocation row naviates to the client page', async function(assert) {
|
||||
// Navigating to the client page requires node:read permission.
|
||||
const policy = server.create('policy', {
|
||||
id: 'node-read',
|
||||
name: 'node-read',
|
||||
rulesJSON: {
|
||||
Node: {
|
||||
Policy: 'read',
|
||||
},
|
||||
},
|
||||
});
|
||||
const clientToken = server.create('token', { type: 'client' });
|
||||
clientToken.policyIds = [policy.id];
|
||||
clientToken.save();
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
|
||||
allocation = server.create('allocation', 'preempter');
|
||||
await Allocation.visit({ id: allocation.id });
|
||||
|
||||
const preemption = allocation.preemptedAllocations
|
||||
.map(id => server.schema.find('allocation', id))
|
||||
.sortBy('modifyIndex')
|
||||
.reverse()[0];
|
||||
const preemptionRow = Allocation.preemptions.objectAt(0);
|
||||
|
||||
await preemptionRow.visitClient();
|
||||
assert.equal(currentURL(), `/clients/${preemption.nodeId}`, 'Node links to node page');
|
||||
|
|
|
@ -282,9 +282,30 @@ module('Acceptance | task group detail', function(hooks) {
|
|||
Object.keys(taskGroup.volumes).length ? 'Yes' : '',
|
||||
'Volumes'
|
||||
);
|
||||
});
|
||||
|
||||
test('clicking the client ID in the allocation row naviates to the client page', async function(assert) {
|
||||
// Navigating to the client page requires node:read permission.
|
||||
const policy = server.create('policy', {
|
||||
id: 'node-read',
|
||||
name: 'node-read',
|
||||
rulesJSON: {
|
||||
Node: {
|
||||
Policy: 'read',
|
||||
},
|
||||
},
|
||||
});
|
||||
const clientToken = server.create('token', { type: 'client' });
|
||||
clientToken.policyIds = [policy.id];
|
||||
clientToken.save();
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
|
||||
await TaskGroup.visit({ id: job.id, name: taskGroup.name });
|
||||
|
||||
const allocation = allocations.sortBy('modifyIndex').reverse()[0];
|
||||
const allocationRow = TaskGroup.allocations.objectAt(0);
|
||||
|
||||
await allocationRow.visitClient();
|
||||
|
||||
assert.equal(currentURL(), `/clients/${allocation.nodeId}`, 'Node links to node page');
|
||||
});
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import { module, test } from 'qunit';
|
|||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
|
||||
import Tokens from 'nomad-ui/tests/pages/settings/tokens';
|
||||
|
||||
// eslint-disable-next-line ember/no-test-module-for
|
||||
export default function moduleForJob(title, context, jobFactory, additionalTests) {
|
||||
|
@ -30,19 +31,28 @@ export default function moduleForJob(title, context, jobFactory, additionalTests
|
|||
});
|
||||
|
||||
test('visiting /jobs/:job_id', async function(assert) {
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}`, job.namespace)
|
||||
const expectedURL = new URL(
|
||||
urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}`, job.namespace),
|
||||
window.location
|
||||
);
|
||||
const gotURL = new URL(currentURL(), window.location);
|
||||
|
||||
assert.deepEqual(gotURL.path, expectedURL.path);
|
||||
assert.deepEqual(gotURL.searchParams, expectedURL.searchParams);
|
||||
assert.equal(document.title, `Job ${job.name} - Nomad`);
|
||||
});
|
||||
|
||||
test('the subnav links to overview', async function(assert) {
|
||||
await JobDetail.tabFor('overview').visit();
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}`, job.namespace)
|
||||
|
||||
const expectedURL = new URL(
|
||||
urlWithNamespace(`/jobs/${encodeURIComponent(job.id)}`, job.namespace),
|
||||
window.location
|
||||
);
|
||||
const gotURL = new URL(currentURL(), window.location);
|
||||
|
||||
assert.deepEqual(gotURL.path, expectedURL.path);
|
||||
assert.deepEqual(gotURL.searchParams, expectedURL.searchParams);
|
||||
});
|
||||
|
||||
test('the subnav links to definition', async function(assert) {
|
||||
|
@ -128,6 +138,23 @@ export function moduleForJobWithClientStatus(title, jobFactory, additionalTests)
|
|||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(async function() {
|
||||
// Displaying the job status in client requires node:read permission.
|
||||
const policy = server.create('policy', {
|
||||
id: 'node-read',
|
||||
name: 'node-read',
|
||||
rulesJSON: {
|
||||
Node: {
|
||||
Policy: 'read',
|
||||
},
|
||||
},
|
||||
});
|
||||
const clientToken = server.create('token', { type: 'client' });
|
||||
clientToken.policyIds = [policy.id];
|
||||
clientToken.save();
|
||||
|
||||
window.localStorage.clear();
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
|
||||
const clients = server.createList('node', 3, {
|
||||
datacenter: 'dc1',
|
||||
status: 'ready',
|
||||
|
@ -143,6 +170,23 @@ export function moduleForJobWithClientStatus(title, jobFactory, additionalTests)
|
|||
}
|
||||
});
|
||||
|
||||
test('job status summary is collapsed when not authorized', async function(assert) {
|
||||
const clientToken = server.create('token', { type: 'client' });
|
||||
await Tokens.visit();
|
||||
await Tokens.secret(clientToken.secretId).submit();
|
||||
|
||||
await JobDetail.visit({ id: job.id, namespace: job.namespace });
|
||||
|
||||
assert.ok(
|
||||
JobDetail.jobClientStatusSummary.toggle.isDisabled,
|
||||
'Job client status summar is disabled'
|
||||
);
|
||||
assert.equal(
|
||||
JobDetail.jobClientStatusSummary.toggle.tooltip,
|
||||
'You don’t have permission to read clients'
|
||||
);
|
||||
});
|
||||
|
||||
test('the subnav links to clients', async function(assert) {
|
||||
await JobDetail.tabFor('clients').visit();
|
||||
assert.equal(
|
||||
|
@ -153,13 +197,13 @@ export function moduleForJobWithClientStatus(title, jobFactory, additionalTests)
|
|||
|
||||
test('job status summary is shown in the overview', async function(assert) {
|
||||
assert.ok(
|
||||
JobDetail.jobClientStatusSummary.isPresent,
|
||||
JobDetail.jobClientStatusSummary.statusBar.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 legendItem = JobDetail.jobClientStatusSummary.statusBar.legend.clickableItems[0];
|
||||
const status = legendItem.label;
|
||||
await legendItem.click();
|
||||
|
||||
|
@ -174,7 +218,7 @@ export function moduleForJobWithClientStatus(title, jobFactory, additionalTests)
|
|||
});
|
||||
|
||||
test('clicking in a slice takes you to a pre-filtered clients table', async function(assert) {
|
||||
const slice = JobDetail.jobClientStatusSummary.slices[0];
|
||||
const slice = JobDetail.jobClientStatusSummary.statusBar.slices[0];
|
||||
const status = slice.label;
|
||||
await slice.click();
|
||||
|
||||
|
|
|
@ -14,10 +14,12 @@ module('Integration | Component | allocation row', function(hooks) {
|
|||
hooks.beforeEach(function() {
|
||||
fragmentSerializerInitializer(this.owner);
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.token = this.owner.lookup('service:token');
|
||||
this.server = startMirage();
|
||||
this.server.create('namespace');
|
||||
this.server.create('node');
|
||||
this.server.create('job', { createAllocations: false });
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
hooks.afterEach(function() {
|
||||
|
@ -79,6 +81,24 @@ module('Integration | Component | allocation row', function(hooks) {
|
|||
});
|
||||
|
||||
test('Allocation row shows warning when it requires drivers that are unhealthy on the node it is running on', async function(assert) {
|
||||
// Driver health status require node:read permission.
|
||||
const policy = server.create('policy', {
|
||||
id: 'node-read',
|
||||
name: 'node-read',
|
||||
rulesJSON: {
|
||||
Node: {
|
||||
Policy: 'read',
|
||||
},
|
||||
},
|
||||
});
|
||||
const clientToken = server.create('token', { type: 'client' });
|
||||
clientToken.policyIds = [policy.id];
|
||||
clientToken.save();
|
||||
|
||||
// Set and fetch ACL token.
|
||||
window.localStorage.nomadTokenSecret = clientToken.secretId;
|
||||
this.token.fetchSelfTokenAndPolicies.perform();
|
||||
|
||||
const node = this.server.schema.nodes.first();
|
||||
const drivers = node.drivers;
|
||||
Object.values(drivers).forEach(driver => {
|
||||
|
|
|
@ -72,7 +72,17 @@ export default create({
|
|||
return this.packStats.toArray().findBy('id', id);
|
||||
},
|
||||
|
||||
jobClientStatusSummary: jobClientStatusBar('[data-test-job-client-status-bar]'),
|
||||
jobClientStatusSummary: {
|
||||
scope: '[data-test-job-client-summary]',
|
||||
statusBar: jobClientStatusBar('[data-test-job-client-status-bar]'),
|
||||
toggle: {
|
||||
scope: '[data-test-accordion-head] [data-test-accordion-toggle]',
|
||||
click: clickable(),
|
||||
isDisabled: attribute('disabled'),
|
||||
tooltip: attribute('aria-label'),
|
||||
},
|
||||
},
|
||||
|
||||
childrenSummary: isPresent('[data-test-job-summary] [data-test-children-status-bar]'),
|
||||
allocationsSummary: isPresent('[data-test-job-summary] [data-test-allocation-status-bar]'),
|
||||
|
||||
|
|
|
@ -8,26 +8,28 @@ module('Unit | Ability | client', function(hooks) {
|
|||
setupTest(hooks);
|
||||
setupAbility('client')(hooks);
|
||||
|
||||
test('it permits client write when ACLs are disabled', function(assert) {
|
||||
test('it permits client read and write when ACLs are disabled', function(assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: false,
|
||||
});
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canRead);
|
||||
assert.ok(this.ability.canWrite);
|
||||
});
|
||||
|
||||
test('it permits client write for management tokens', function(assert) {
|
||||
test('it permits client read and write for management tokens', function(assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'management' },
|
||||
});
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canRead);
|
||||
assert.ok(this.ability.canWrite);
|
||||
});
|
||||
|
||||
test('it permits client write for tokens with a policy that has node-write', function(assert) {
|
||||
test('it permits client read and write for tokens with a policy that has node-write', function(assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
|
@ -43,10 +45,11 @@ module('Unit | Ability | client', function(hooks) {
|
|||
});
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canRead);
|
||||
assert.ok(this.ability.canWrite);
|
||||
});
|
||||
|
||||
test('it permits client write for tokens with a policy that allows write and another policy that disallows it', function(assert) {
|
||||
test('it permits client read and write for tokens with a policy that allows write and another policy that disallows it', function(assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
|
@ -69,10 +72,11 @@ module('Unit | Ability | client', function(hooks) {
|
|||
});
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canRead);
|
||||
assert.ok(this.ability.canWrite);
|
||||
});
|
||||
|
||||
test('it blocks client write for tokens with a policy that does not allow node-write', function(assert) {
|
||||
test('it permits client read and blocks client write for tokens with a policy that does not allow node-write', function(assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
|
@ -88,6 +92,23 @@ module('Unit | Ability | client', function(hooks) {
|
|||
});
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.ok(this.ability.canRead);
|
||||
assert.notOk(this.ability.canWrite);
|
||||
});
|
||||
|
||||
test('it blocks client read and write for tokens without a node policy', function(assert) {
|
||||
const mockToken = Service.extend({
|
||||
aclEnabled: true,
|
||||
selfToken: { type: 'client' },
|
||||
selfTokenPolicies: [
|
||||
{
|
||||
rulesJSON: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
this.owner.register('service:token', mockToken);
|
||||
|
||||
assert.notOk(this.ability.canRead);
|
||||
assert.notOk(this.ability.canWrite);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -35,6 +35,22 @@ class NodeMock {
|
|||
}
|
||||
}
|
||||
|
||||
class AllocationMock {
|
||||
constructor(node, clientStatus) {
|
||||
this.node = node;
|
||||
this.clientStatus = clientStatus;
|
||||
}
|
||||
|
||||
belongsTo() {
|
||||
const self = this;
|
||||
return {
|
||||
id() {
|
||||
return self.node.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');
|
||||
|
@ -42,7 +58,7 @@ module('Unit | Util | JobClientStatus', function() {
|
|||
const job = {
|
||||
datacenters: ['dc1'],
|
||||
status: 'running',
|
||||
allocations: [{ node, clientStatus: 'running' }],
|
||||
allocations: [new AllocationMock(node, 'running')],
|
||||
taskGroups: [{}],
|
||||
};
|
||||
const expected = {
|
||||
|
@ -75,9 +91,9 @@ module('Unit | Util | JobClientStatus', function() {
|
|||
datacenters: ['dc1'],
|
||||
status: 'running',
|
||||
allocations: [
|
||||
{ node, clientStatus: 'running' },
|
||||
{ node, clientStatus: 'failed' },
|
||||
{ node, clientStatus: 'running' },
|
||||
new AllocationMock(node, 'running'),
|
||||
new AllocationMock(node, 'failed'),
|
||||
new AllocationMock(node, 'running'),
|
||||
],
|
||||
taskGroups: [{}, {}, {}],
|
||||
};
|
||||
|
@ -111,9 +127,9 @@ module('Unit | Util | JobClientStatus', function() {
|
|||
datacenters: ['dc1'],
|
||||
status: 'running',
|
||||
allocations: [
|
||||
{ node, clientStatus: 'lost' },
|
||||
{ node, clientStatus: 'lost' },
|
||||
{ node, clientStatus: 'lost' },
|
||||
new AllocationMock(node, 'lost'),
|
||||
new AllocationMock(node, 'lost'),
|
||||
new AllocationMock(node, 'lost'),
|
||||
],
|
||||
taskGroups: [{}, {}, {}],
|
||||
};
|
||||
|
@ -147,9 +163,9 @@ module('Unit | Util | JobClientStatus', function() {
|
|||
datacenters: ['dc1'],
|
||||
status: 'running',
|
||||
allocations: [
|
||||
{ node, clientStatus: 'failed' },
|
||||
{ node, clientStatus: 'failed' },
|
||||
{ node, clientStatus: 'failed' },
|
||||
new AllocationMock(node, 'failed'),
|
||||
new AllocationMock(node, 'failed'),
|
||||
new AllocationMock(node, 'failed'),
|
||||
],
|
||||
taskGroups: [{}, {}, {}],
|
||||
};
|
||||
|
@ -183,9 +199,9 @@ module('Unit | Util | JobClientStatus', function() {
|
|||
datacenters: ['dc1'],
|
||||
status: 'running',
|
||||
allocations: [
|
||||
{ node, clientStatus: 'running' },
|
||||
{ node, clientStatus: 'running' },
|
||||
{ node, clientStatus: 'running' },
|
||||
new AllocationMock(node, 'running'),
|
||||
new AllocationMock(node, 'running'),
|
||||
new AllocationMock(node, 'running'),
|
||||
],
|
||||
taskGroups: [{}, {}, {}, {}],
|
||||
};
|
||||
|
@ -251,9 +267,9 @@ module('Unit | Util | JobClientStatus', function() {
|
|||
datacenters: ['dc1'],
|
||||
status: 'pending',
|
||||
allocations: [
|
||||
{ node, clientStatus: 'starting' },
|
||||
{ node, clientStatus: 'starting' },
|
||||
{ node, clientStatus: 'starting' },
|
||||
new AllocationMock(node, 'starting'),
|
||||
new AllocationMock(node, 'starting'),
|
||||
new AllocationMock(node, 'starting'),
|
||||
],
|
||||
taskGroups: [{}, {}, {}, {}],
|
||||
};
|
||||
|
@ -288,9 +304,9 @@ module('Unit | Util | JobClientStatus', function() {
|
|||
datacenters: ['dc1'],
|
||||
status: 'running',
|
||||
allocations: [
|
||||
{ node: node1, clientStatus: 'running' },
|
||||
{ node: node2, clientStatus: 'failed' },
|
||||
{ node: node1, clientStatus: 'running' },
|
||||
new AllocationMock(node1, 'running'),
|
||||
new AllocationMock(node2, 'failed'),
|
||||
new AllocationMock(node1, 'running'),
|
||||
],
|
||||
taskGroups: [{}, {}],
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue