Unverified Commit d89d071b authored by Luiz Aoqui's avatar Luiz Aoqui
Browse files

ui: policy system and sysbatch job details clients tab

parent 2a2902d3
Showing with 234 additions and 146 deletions
+234 -146
......@@ -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);
......
<tr>
<th class="is-narrow"></th>
<tr class="job-client-status-row is-interactive" {{on "click" (fn @onClick this.row.node)}}>
<td data-test-short-id>
<LinkTo @route="clients.client" @model={{this.node.id}} class="is-primary">
{{this.node.shortId}}
</LinkTo>
<strong>{{this.row.node.shortId}}</strong>
</td>
<td data-test-short-id>
{{this.node.name}}
<td data-test-short-id class="is-200px is-truncatable">
{{this.row.node.name}}
</td>
<td data-test-create-time>
<span class="tooltip" aria-label="{{format-month-ts this.eldestCreateTime}}">
{{moment-from-now this.eldestCreateTime}}
{{#if this.row.createTime}}
<span class="tooltip" aria-label="{{format-month-ts this.row.createTime}}">
{{moment-from-now this.row.createTime}}
</span>
{{else}}
-
{{/if}}
</td>
<td data-test-modify-time>
<span class="tooltip" aria-label="{{format-month-ts this.node.modifyTime}}">
{{moment-from-now this.allocation.modifyTime}}
{{#if this.row.modifyTime}}
<span class="tooltip" aria-label="{{format-month-ts this.row.modifyTime}}">
{{moment-from-now this.row.modifyTime}}
</span>
{{else}}
-
{{/if}}
</td>
<td data-test-client-status class="is-one-line">
<span class="color-swatch {{this.status}}"></span>
{{this.status}}
<span class="color-swatch {{this.jobStatusClass}}"></span>
{{this.humanizedJobStatus}}
</td>
<td data-test-client-allocations class="is-one-line">
<td data-test-client-allocations class="allocation-summary is-one-line">
{{#if this.shouldDisplayAllocationSummary}}
<div class="inline-chart">
<AllocationStatusBar @allocationContainer={{this.allocationContainer}} @isNarrow={{true}} />
</div>
{{else}}
Not Scheduled
<div class="is-empty">{{this.allocationSummaryPlaceholder}}</div>
{{/if}}
</td>
</tr>
\ No newline at end of file
import { classNames, tagName } from '@ember-decorators/component';
import EmberObject from '@ember/object';
import Component from '@glimmer/component';
@tagName('tr')
@classNames('client-row', 'is-interactive')
export default class ClientRowComponent extends Component {
get shouldDisplayAllocationSummary() {
return this.status !== 'notScheduled';
export default class ClientRow extends Component {
// Attribute set in the template as @onClick.
onClick() {}
get row() {
return this.args.row.model;
}
get node() {
return this.args.node.model;
get shouldDisplayAllocationSummary() {
return this.args.row.model.jobStatus !== 'notScheduled';
}
get eldestCreateTime() {
let eldest = null;
for (const allocation of this.node.id) {
if (!eldest || allocation.createTime < eldest) {
eldest = allocation.createTime;
}
get allocationSummaryPlaceholder() {
switch (this.args.row.model.jobStatus) {
case 'notScheduled':
return 'Not Scheduled';
default:
return '';
}
return eldest;
}
get mostRecentModifyTime() {
let mostRecent = null;
for (const allocation of this.node.id) {
if (!mostRecent || allocation.modifyTime > mostRecent) {
mostRecent = allocation.createTime;
}
get humanizedJobStatus() {
switch (this.args.row.model.jobStatus) {
case 'notScheduled':
return 'not scheduled';
default:
return this.args.row.model.jobStatus;
}
return mostRecent;
}
get status() {
return this.args.jobClientStatus.byNode[this.node.id];
get jobStatusClass() {
switch (this.args.row.model.jobStatus) {
case 'notScheduled':
return 'not-scheduled';
default:
return this.args.row.model.jobStatus;
}
}
get allocationContainer() {
......@@ -45,46 +49,38 @@ export default class ClientRowComponent extends Component {
startingAllocs: 0,
lostAllocs: 0,
};
// query by allocations for job then group by node use the mapBy method
if (this.status === 'notScheduled') return EmberObject.create(...statusSummary);
const allocsByNodeID = {};
this.args.allocations.forEach(a => {
const nodeId = a.node.get('id');
if (!allocsByNodeID[nodeId]) {
allocsByNodeID[nodeId] = [];
}
allocsByNodeID[nodeId].push(a);
});
for (const allocation of allocsByNodeID[this.node.id]) {
if (this.status === 'queued') {
statusSummary.queuedAllocs = allocsByNodeID[this.node.id].length;
switch (this.args.row.model.jobStatus) {
case 'notSchedule':
break;
} else if (this.status === 'starting') {
statusSummary.startingAllocs = allocsByNodeID[this.node.id].length;
case 'queued':
statusSummary.queuedAllocs = this.args.row.model.allocations.length;
break;
} else if (this.status === 'notScheduled') {
case 'starting':
statusSummary.startingAllocs = this.args.row.model.allocations.length;
break;
}
const { clientStatus } = allocation;
switch (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;
}
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,
});
......
......@@ -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);
......
/* 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 Sortable from 'nomad-ui/mixins/sortable';
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';
......@@ -12,7 +13,7 @@ import classic from 'ember-classic-decorator';
@classic
export default class ClientsController extends Controller.extend(
Sortable,
SortableFactory(['id', 'name', 'jobStatus']),
Searchable,
WithNamespaceResetting
) {
......@@ -40,51 +41,50 @@ export default class ClientsController extends Controller.extend(
},
];
currentPage = 1;
pageSize = 25;
sortProperty = 'modifyIndex';
sortDescending = true;
@computed
get searchProps() {
return ['id', 'name', 'taskGroupName'];
}
qpStatus = '';
qpDatacenter = '';
qpNodeClass = '';
currentPage = 1;
pageSize = 25;
sortProperty = 'jobStatus';
sortDescending = false;
@selection('qpStatus') selectionStatus;
@selection('qpDatacenter') selectionDatacenter;
@selection('qpNodeClass') selectionNodeClass;
@computed
get optionsStatus() {
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' },
];
}
@alias('model') job;
@jobClientStatus('nodes', 'job') jobClientStatus;
@jobClientStatus('allNodes', 'job') jobClientStatus;
@alias('filteredNodes') listToSort;
@alias('listSorted') listToSearch;
@alias('listSearched') sortedClients;
get nodes() {
@computed
get allNodes() {
return this.store.peekAll('node');
}
@computed('nodes', 'selectionStatus', 'selectionDatacenter', 'selectionNodeClass')
@computed('allNodes')
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',
'selectionNodeClass'
)
get filteredNodes() {
const {
selectionStatus: statuses,
......@@ -92,43 +92,69 @@ export default class ClientsController extends Controller.extend(
selectionNodeClass: nodeclasses,
} = 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 (nodeclasses.length && !nodeclasses.includes(node.nodeClass)) {
return false;
}
return true;
});
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 (nodeclasses.length && !nodeclasses.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('selectionDatacenter', 'job.datacenters')
@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() {
// eslint-disable-next-line ember/no-incorrect-calls-with-inline-anonymous-functions
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(
this.qpDatacenter,
serialize(intersection(this.job.datacenters, this.selectionDatacenter))
);
this.set('qpDatacenter', serialize(intersection(datacenters, this.selectionDatacenter)));
});
return this.job.datacenters.sort().map(dc => ({ key: dc, label: dc }));
return datacenters.sort().map(dc => ({ key: dc, label: dc }));
}
@computed('selectionNodeClass', 'nodes')
get optionsNodeClass() {
const nodeClasses = Array.from(new Set(this.nodes.mapBy('nodeClass')));
// // eslint-disable-next-line ember/no-incorrect-calls-with-inline-anonymous-functions
// scheduleOnce('actions', () => {
// // eslint-disable-next-line ember/no-side-effects
// this.set(this.qpNodeClass, serialize(intersection(nodeClasses, this.selectionNodeClass)));
// });
const nodeClasses = 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('qpNodeClass', serialize(intersection(nodeClasses, this.selectionNodeClass)));
});
return nodeClasses.sort().map(nodeClass => ({ key: nodeClass, label: nodeClass }));
}
......@@ -142,3 +168,23 @@ export default class ClientsController extends Controller.extend(
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;
}
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 {
// TODO: add watcher for nodes.
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;
}
......@@ -21,6 +21,7 @@
@import './components/gutter-toggle';
@import './components/image-file.scss';
@import './components/inline-definitions';
@import './components/job-client-status-row';
@import './components/job-diff';
@import './components/json-viewer';
@import './components/legend';
......
.job-client-status-row {
.allocation-summary {
.is-empty {
color: darken($grey-blue, 20%);
text-align: center;
font-style: italic;
}
}
}
......@@ -15,8 +15,8 @@
<div class="button-bar">
<MultiSelectDropdown
data-test-status-facet
@label="Status"
@options={{this.optionsStatus}}
@label="Job Status"
@options={{this.optionsJobStatus}}
@selection={{this.selectionStatus}}
@onSelect={{action this.setFacetQueryParam "qpStatus"}}
/>
......@@ -51,20 +51,19 @@
@class="with-foot" as |t|
>
<t.head>
<th class="is-narrow"></th>
<t.sort-by @prop="id">
Client ID
</t.sort-by>
<t.sort-by @prop="name">
<t.sort-by @prop="name" class="is-200px is-truncatable">
Client Name
</t.sort-by>
<t.sort-by @prop="createIndex" @title="Create Index">
<t.sort-by @prop="createTime" @title="Create Time">
Created
</t.sort-by>
<t.sort-by @prop="modifyIndex" @title="Modify Index">
<t.sort-by @prop="modifyTime" @title="Modify Time">
Modified
</t.sort-by>
<t.sort-by @prop="statusIndex">
<t.sort-by @prop="jobStatus">
Job Status
</t.sort-by>
<th class="is-3">
......@@ -74,11 +73,8 @@
<t.body as |row|>
<ClientRow
@data-test-client={{"sdf"}}
@jobClientStatus={{this.jobClientStatus}}
@allocations={{this.job.allocations}}
@node={{row}}
@context="job"
@onClick={{action "gotoClient" row}}
@row={{row}}
@onClick={{this.gotoClient}}
/>
</t.body>
</ListTable>
......
......@@ -5,6 +5,12 @@ export function sysbatchSmall(server) {
status: 'ready',
});
// Create some clients not targeted by the sysbatch job.
server.createList('node', 3, {
datacenter: 'dc3',
status: 'ready',
});
// Job with 1 task group.
const job1 = server.create('job', {
status: 'running',
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment