[ui, deployments] Add status panel to child jobs (#17217)

* Treated same-route as sub-route and didnt cancel watchers

* Adds panel to child jobs and sub-sorts

* removed the safety check in module-for-job tests

* [ui] Adds status panel to Sysbatch jobs (#17243)

* In working out periodic/param child jobs, realized the intersection with sysbatch is high enough that it ought to be worked on now

* Further removal of jobclientstatussummary

* Explicitly making mocked jobs in no-deployment mode

* remove last remnants of job-client-status-summary component

* Screwed up my sorting order a few commits ago; this corrects it

* noActiveDeployment gonna be the death of me
This commit is contained in:
Phil Renaud
2023-05-19 15:51:35 -04:00
committed by GitHub
parent 2275a83cbf
commit 2600ab7fce
13 changed files with 133 additions and 254 deletions

View File

@@ -1,52 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@ember/component';
import { action, computed } from '@ember/object';
import { inject as service } from '@ember/service';
import { camelize } from '@ember/string';
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 {
@service router;
@service store;
@jobClientStatus('nodes', 'job') jobClientStatus;
get nodes() {
return this.store.peekAll('node');
}
job = null;
@action
gotoClients(statusFilter) {
this.router.transitionTo('jobs.job.clients', this.job, {
queryParams: {
status: JSON.stringify(statusFilter),
},
});
}
@computed
get isExpanded() {
const storageValue = window.localStorage.nomadExpandJobClientStatusSummary;
return storageValue != null ? JSON.parse(storageValue) : true;
}
@action
onSliceClick(ev, slice) {
this.gotoClients([camelize(slice.className)]);
}
persist(item, isOpen) {
window.localStorage.nomadExpandJobClientStatusSummary = isOpen;
this.notifyPropertyChange('isExpanded');
}
}

View File

@@ -76,7 +76,21 @@ export default class JobStatusPanelSteadyComponent extends Component {
.filter(
(a) => a.clientStatus !== 'running' && a.clientStatus !== 'pending'
)
.sortBy('jobVersion')
.sort((a, b) => {
// First sort by jobVersion
if (a.jobVersion > b.jobVersion) return 1;
if (a.jobVersion < b.jobVersion) return -1;
// If jobVersion is the same, sort by status order
if (a.jobVersion === b.jobVersion) {
return (
jobAllocStatuses[this.args.job.type].indexOf(b.clientStatus) -
jobAllocStatuses[this.args.job.type].indexOf(a.clientStatus)
);
} else {
return 0;
}
})
.reverse();
// Iterate over the sorted allocs
@@ -137,7 +151,7 @@ export default class JobStatusPanelSteadyComponent extends Component {
}
get atMostOneAllocPerNode() {
return this.args.job.type === 'system';
return this.args.job.type === 'system' || this.args.job.type === 'sysbatch';
}
get versions() {

View File

@@ -26,9 +26,6 @@
DasRecommendations=(component
"job-page/parts/das-recommendations" job=@job
)
JobClientStatusSummary=(component
"job-page/parts/job-client-status-summary" job=@job
)
Children=(component "job-page/parts/children" job=@job)
StatusPanel=(component

View File

@@ -22,8 +22,7 @@
</span>
</:before-namespace>
</jobPage.ui.StatsBox>
<jobPage.ui.JobClientStatusSummary />
<jobPage.ui.Summary @forceCollapsed={{@job.hasClientStatus}} />
<jobPage.ui.StatusPanel @statusMode={{@statusMode}} @setStatusMode={{@setStatusMode}} />
<jobPage.ui.PlacementFailures />
<jobPage.ui.TaskGroups @sortProperty={{@sortProperty}} @sortDescending={{@sortDescending}} />
<jobPage.ui.RecentAllocations @activeTask={{@activeTask}} @setActiveTaskQueryParam={{@setActiveTaskQueryParam}} />

View File

@@ -1,116 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: MPL-2.0
~}}
{{#if this.job.hasClientStatus}}
<ListAccordion
data-test-job-client-summary
@source={{array this.job}}
@key="id"
@startExpanded={{this.isExpanded}}
@onToggle={{action this.persist}} as |a|
>
{{#if (can "read client")}}
<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>
{{else}}
<a.head @buttonLabel={{if a.isOpen "collapse" "expand"}}>
<div class="columns">
<div class="column is-minimum nowrap">
Job Status in Client
<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>
</div>
</a.head>
<a.body>
<div class="empty-message">
<h3 data-test-nodes-not-authorized class="empty-message-headline">
Not Authorized
</h3>
<p class="empty-message-body">
Your
<LinkTo @route="settings.tokens">
ACL token
</LinkTo>
does not provide
<code>
node:read
</code>
permission.
</p>
</div>
</a.body>
{{/if}}
</ListAccordion>
{{/if}}

View File

@@ -22,8 +22,7 @@
</span>
</:before-namespace>
</jobPage.ui.StatsBox>
<jobPage.ui.JobClientStatusSummary />
<jobPage.ui.Summary @forceCollapsed={{@job.hasClientStatus}} />
<jobPage.ui.StatusPanel @statusMode={{@statusMode}} @setStatusMode={{@setStatusMode}} />
<jobPage.ui.PlacementFailures />
<jobPage.ui.TaskGroups @sortProperty={{@sortProperty}} @sortDescending={{@sortDescending}} />
<jobPage.ui.RecentAllocations @activeTask={{@activeTask}} @setActiveTaskQueryParam={{@setActiveTaskQueryParam}} />

View File

@@ -8,8 +8,7 @@
<jobPage.ui.Error />
<jobPage.ui.Title />
<jobPage.ui.StatsBox />
<jobPage.ui.JobClientStatusSummary />
<jobPage.ui.Summary @forceCollapsed="true" />
<jobPage.ui.StatusPanel @statusMode={{@statusMode}} @setStatusMode={{@setStatusMode}} />
<jobPage.ui.PlacementFailures />
<jobPage.ui.TaskGroups @sortProperty={{@sortProperty}} @sortDescending={{@sortDescending}} />
<jobPage.ui.RecentAllocations @activeTask={{@activeTask}} @setActiveTaskQueryParam={{@setActiveTaskQueryParam}} />

View File

@@ -11,7 +11,8 @@
export const jobAllocStatuses = {
service: ['running', 'pending', 'failed', 'lost', 'unplaced'],
system: ['running', 'pending', 'failed', 'lost', 'unplaced'],
batch: ['running', 'pending', 'failed', 'lost', 'complete', 'unplaced'],
batch: ['running', 'pending', 'complete', 'failed', 'lost', 'unplaced'],
sysbatch: ['running', 'pending', 'complete', 'failed', 'lost', 'unplaced'],
};
export const jobTypes = ['service', 'system', 'batch'];
export const jobTypes = ['service', 'system', 'batch', 'sysbatch'];

View File

@@ -345,6 +345,7 @@ export default Factory.extend({
datacenters: job.datacenters,
createAllocations: job.createAllocations,
shallow: job.shallow,
noActiveDeployment: job.noActiveDeployment,
});
}

View File

@@ -33,7 +33,11 @@ moduleForJob('Acceptance | job detail (system)', 'allocations', () =>
);
moduleForJob('Acceptance | job detail (sysbatch)', 'allocations', () =>
server.create('job', { type: 'sysbatch', shallow: true })
server.create('job', {
type: 'sysbatch',
shallow: true,
noActiveDeployment: true,
})
);
moduleForJobWithClientStatus(
@@ -44,6 +48,7 @@ moduleForJobWithClientStatus(
datacenters: ['dc1'],
type: 'sysbatch',
createAllocations: false,
noActiveDeployment: true,
})
);
@@ -57,6 +62,7 @@ moduleForJobWithClientStatus(
type: 'sysbatch',
namespaceId: namespace.name,
createAllocations: false,
noActiveDeployment: true,
});
}
);
@@ -71,6 +77,7 @@ moduleForJobWithClientStatus(
type: 'sysbatch',
namespaceId: namespace.name,
createAllocations: false,
noActiveDeployment: true,
});
}
);
@@ -80,6 +87,7 @@ moduleForJob('Acceptance | job detail (sysbatch child)', 'allocations', () => {
childrenCount: 1,
shallow: true,
datacenters: ['dc1'],
noActiveDeployment: true,
});
return server.db.jobs.where({ parentId: parent.id })[0];
});
@@ -91,6 +99,7 @@ moduleForJobWithClientStatus(
childrenCount: 1,
shallow: true,
datacenters: ['dc1'],
noActiveDeployment: true,
});
return server.db.jobs.where({ parentId: parent.id })[0];
}
@@ -105,6 +114,7 @@ moduleForJobWithClientStatus(
shallow: true,
namespaceId: namespace.name,
datacenters: ['dc1'],
noActiveDeployment: true,
});
return server.db.jobs.where({ parentId: parent.id })[0];
}
@@ -119,6 +129,7 @@ moduleForJobWithClientStatus(
shallow: true,
namespaceId: namespace.name,
datacenters: ['*'],
noActiveDeployment: true,
});
return server.db.jobs.where({ parentId: parent.id })[0];
}
@@ -168,7 +179,11 @@ moduleForJob(
moduleForJob(
'Acceptance | job detail (parameterized)',
'children',
() => server.create('job', 'parameterized', { shallow: true }),
() =>
server.create('job', 'parameterized', {
shallow: true,
noActiveDeployment: true,
}),
{
'the default sort is submitTime descending': async (job, assert) => {
const mostRecentLaunch = server.db.jobs
@@ -221,6 +236,7 @@ moduleForJob(
const parent = server.create('job', 'parameterized', {
childrenCount: 1,
shallow: true,
noActiveDeployment: true,
});
return server.db.jobs.where({ parentId: parent.id })[0];
}

View File

@@ -254,6 +254,93 @@ module('Acceptance | job status panel', function (hooks) {
});
});
test('After running/pending allocations are covered, fill in allocs by jobVersion, descending (batch)', async function (assert) {
assert.expect(7);
let job = server.create('job', {
status: 'running',
datacenters: ['*'],
type: 'batch',
resourceSpec: ['M: 256, C: 500'], // a single group
createAllocations: false,
allocStatusDistribution: {
running: 0.5,
failed: 0.3,
unknown: 0,
lost: 0,
complete: 0.2,
},
groupTaskCount: 5,
shallow: true,
version: 5,
noActiveDeployment: true,
});
server.create('allocation', {
jobId: job.id,
clientStatus: 'running',
jobVersion: 5,
});
server.create('allocation', {
jobId: job.id,
clientStatus: 'pending',
jobVersion: 5,
});
server.create('allocation', {
jobId: job.id,
clientStatus: 'running',
jobVersion: 3,
});
server.create('allocation', {
jobId: job.id,
clientStatus: 'failed',
jobVersion: 4,
});
server.create('allocation', {
jobId: job.id,
clientStatus: 'complete',
jobVersion: 4,
});
server.create('allocation', {
jobId: job.id,
clientStatus: 'lost',
jobVersion: 5,
});
await visit(`/jobs/${job.id}`);
assert.dom('.job-status-panel').exists();
// We expect to see 5 represented-allocations, since that's the number in our groupTaskCount
assert
.dom('.ungrouped-allocs .represented-allocation')
.exists({ count: 5 });
// We expect 2 of them to be running, and one to be pending, since running/pending allocations superecede other clientStatuses
assert
.dom('.ungrouped-allocs .represented-allocation.running')
.exists({ count: 2 });
assert
.dom('.ungrouped-allocs .represented-allocation.pending')
.exists({ count: 1 });
// We expect 1 to be lost, since it has the highest jobVersion
assert
.dom('.ungrouped-allocs .represented-allocation.lost')
.exists({ count: 1 });
// We expect the remaining one to be complete, rather than failed, since it comes earlier in the jobAllocStatuses.batch constant
assert
.dom('.ungrouped-allocs .represented-allocation.complete')
.exists({ count: 1 });
assert
.dom('.ungrouped-allocs .represented-allocation.failed')
.doesNotExist();
await percySnapshot(assert, {
percyCSS: `
.allocation-row td { display: none; }
`,
});
});
test('Status Panel groups allocations when they get past a threshold', async function (assert) {
assert.expect(6);

View File

@@ -5,21 +5,14 @@
/* eslint-disable qunit/require-expect */
/* eslint-disable qunit/no-conditional-assertions */
import {
click,
currentRouteName,
currentURL,
visit,
find,
} from '@ember/test-helpers';
import { currentRouteName, currentURL, visit, find } from '@ember/test-helpers';
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 setPolicy from 'nomad-ui/tests/utils/set-policy';
const jobTypesWithStatusPanel = ['service', 'system', 'batch'];
const jobTypesWithStatusPanel = ['service', 'system', 'batch', 'sysbatch'];
async function switchToHistorical() {
await JobDetail.statusModes.historical.click();
}
@@ -55,11 +48,6 @@ export default function moduleForJob(
} else {
await JobDetail.visit({ id: `${job.id}@${job.namespace}` });
}
const hasClientStatus = ['sysbatch'].includes(job.type);
if (context === 'allocations' && hasClientStatus) {
await click("[data-test-accordion-summary-chart='allocation-status']");
}
});
test('visiting /jobs/:job_id', async function (assert) {
@@ -117,7 +105,7 @@ export default function moduleForJob(
if (context === 'allocations') {
test('allocations for the job are shown in the overview', async function (assert) {
if (!job.parentId && jobTypesWithStatusPanel.includes(job.type)) {
if (jobTypesWithStatusPanel.includes(job.type)) {
await switchToHistorical(job);
}
assert.ok(
@@ -157,7 +145,7 @@ export default function moduleForJob(
});
test('clicking legend item navigates to a pre-filtered allocations table', async function (assert) {
if (!job.parentId && jobTypesWithStatusPanel.includes(job.type)) {
if (jobTypesWithStatusPanel.includes(job.type)) {
await switchToHistorical(job);
}
const legendItem = find('.legend li.is-clickable');
@@ -178,7 +166,7 @@ export default function moduleForJob(
});
test('clicking in a slice takes you to a pre-filtered allocations table', async function (assert) {
if (!job.parentId && jobTypesWithStatusPanel.includes(job.type)) {
if (jobTypesWithStatusPanel.includes(job.type)) {
await switchToHistorical(job);
}
const slice = JobDetail.allocationsSummary.slices[0];
@@ -301,46 +289,6 @@ export function moduleForJobWithClientStatus(
assert.equal(currentURL(), expectedURL);
});
test('job status summary is shown in the overview', async function (assert) {
assert.ok(
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.statusBar.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.statusBar.slices[0];
const status = slice.label;
await slice.click();
const encodedStatus = encodeURIComponent(JSON.stringify([status]));
const expectedURL = job.namespace
? `/jobs/${job.name}@${job.namespace}/clients?status=${encodedStatus}`
: `/jobs/${job.name}/clients?status=${encodedStatus}`;
assert.deepEqual(currentURL(), expectedURL, 'url is correct');
});
for (var testName in additionalTests) {
test(testName, async function (assert) {
await additionalTests[testName].call(this, job, assert);
@@ -366,10 +314,6 @@ export function moduleForJobWithClientStatus(
.doesNotExist(
'Job Detail Sub Navigation should not render Clients tab'
);
assert
.dom('[data-test-nodes-not-authorized]')
.exists('Renders Not Authorized message');
});
test('/jobs/job/clients route is protected with authorization logic', async function (assert) {

View File

@@ -93,16 +93,6 @@ export default create({
},
},
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: jobClientStatusBar(
'[data-test-children-status-bar]:not(.is-narrow)'
),