diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js
index 6441d57391c7..076603b11227 100644
--- a/ui/app/adapters/job.js
+++ b/ui/app/adapters/job.js
@@ -10,6 +10,7 @@ import { base64EncodeString } from 'nomad-ui/utils/encode';
import classic from 'ember-classic-decorator';
import { inject as service } from '@ember/service';
import { getOwner } from '@ember/application';
+import { get } from '@ember/object';
@classic
export default class JobAdapter extends WatchableNamespaceIDs {
@@ -202,4 +203,65 @@ export default class JobAdapter extends WatchableNamespaceIDs {
return wsUrl;
}
+
+ // TODO: Handle the in-job-page query for pack meta per https://github.com/hashicorp/nomad/pull/14833
+ query(store, type, query, snapshotRecordArray, options) {
+ options = options || {};
+ options.adapterOptions = options.adapterOptions || {};
+
+ const method = get(options, 'adapterOptions.method') || 'GET';
+ const url = this.urlForQuery(query, type.modelName, method);
+
+ let index = query.index || 1;
+
+ if (index && index > 1) {
+ query.index = index;
+ }
+
+ const signal = get(options, 'adapterOptions.abortController.signal');
+
+ return this.ajax(url, method, {
+ signal,
+ data: query,
+ }).then((payload) => {
+ // If there was a request body, append it to the payload
+ // We can use this in our serializer to maintain returned job order,
+ // even if one of the requested jobs is not found (has been GC'd) so as
+ // not to jostle the user's view.
+ if (query.jobs) {
+ payload._requestBody = query;
+ }
+ return payload;
+ });
+ }
+
+ handleResponse(status, headers) {
+ /**
+ * @type {Object}
+ */
+ const result = super.handleResponse(...arguments);
+ if (result) {
+ result.meta = result.meta || {};
+ if (headers['x-nomad-nexttoken']) {
+ result.meta.nextToken = headers['x-nomad-nexttoken'];
+ }
+ if (headers['x-nomad-index']) {
+ result.meta.index = headers['x-nomad-index'];
+ }
+ }
+ return result;
+ }
+
+ urlForQuery(query, modelName, method) {
+ let baseUrl = `/${this.namespace}/jobs/statuses`;
+ if (method === 'POST' && query.index) {
+ baseUrl += baseUrl.includes('?') ? '&' : '?';
+ baseUrl += `index=${query.index}`;
+ }
+ if (method === 'POST' && query.jobs) {
+ baseUrl += baseUrl.includes('?') ? '&' : '?';
+ baseUrl += 'namespace=*';
+ }
+ return baseUrl;
+ }
}
diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js
index 792e9f4bc0c1..4f5ee74ce222 100644
--- a/ui/app/adapters/watchable.js
+++ b/ui/app/adapters/watchable.js
@@ -24,11 +24,10 @@ export default class Watchable extends ApplicationAdapter {
//
// It's either this weird side-effecting thing that also requires a change
// to ajaxOptions or overriding ajax completely.
- ajax(url, type, options) {
+ ajax(url, type, options = {}) {
const hasParams = hasNonBlockingQueryParams(options);
if (!hasParams || type !== 'GET') return super.ajax(url, type, options);
-
- const params = { ...options.data };
+ let params = { ...options?.data };
delete params.index;
// Options data gets appended as query params as part of ajaxOptions.
@@ -96,6 +95,7 @@ export default class Watchable extends ApplicationAdapter {
additionalParams = {}
) {
const url = this.buildURL(type.modelName, null, null, 'query', query);
+ const method = get(options, 'adapterOptions.method') || 'GET';
let [urlPath, params] = url.split('?');
params = assign(
queryString.parse(params) || {},
@@ -105,15 +105,13 @@ export default class Watchable extends ApplicationAdapter {
);
if (get(options, 'adapterOptions.watch')) {
- // The intended query without additional blocking query params is used
- // to track the appropriate query index.
params.index = this.watchList.getIndexFor(
`${urlPath}?${queryString.stringify(query)}`
);
}
const signal = get(options, 'adapterOptions.abortController.signal');
- return this.ajax(urlPath, 'GET', {
+ return this.ajax(urlPath, method, {
signal,
data: params,
}).then((payload) => {
diff --git a/ui/app/components/job-row.js b/ui/app/components/job-row.js
index 9cc7fe655063..6c5ba2e78c85 100644
--- a/ui/app/components/job-row.js
+++ b/ui/app/components/job-row.js
@@ -3,39 +3,139 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-import Component from '@ember/component';
+// @ts-check
+
+import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
-import { lazyClick } from '../helpers/lazy-click';
-import {
- classNames,
- tagName,
- attributeBindings,
-} from '@ember-decorators/component';
-import classic from 'ember-classic-decorator';
-
-@classic
-@tagName('tr')
-@classNames('job-row', 'is-interactive')
-@attributeBindings('data-test-job-row')
+import { task } from 'ember-concurrency';
+
export default class JobRow extends Component {
@service router;
@service store;
@service system;
- job = null;
+ /**
+ * Promotion of a deployment will error if the canary allocations are not of status "Healthy";
+ * this function will check for that and disable the promote button if necessary.
+ * @returns {boolean}
+ */
+ get canariesHealthy() {
+ const relevantAllocs = this.args.job.allocations.filter(
+ (a) => !a.isOld && a.isCanary && !a.hasBeenRescheduled
+ );
+ return (
+ relevantAllocs.length &&
+ relevantAllocs.every((a) => a.clientStatus === 'running' && a.isHealthy)
+ );
+ }
+
+ /**
+ * Used to inform the user that an allocation has entered into a perment state of failure:
+ * That is, it has exhausted its restarts and its reschedules and is in a terminal state.
+ */
+ get someCanariesHaveFailedAndWontReschedule() {
+ const relevantAllocs = this.args.job.allocations.filter(
+ (a) => !a.isOld && a.isCanary && !a.hasBeenRescheduled
+ );
+
+ return relevantAllocs.some(
+ (a) =>
+ a.clientStatus === 'failed' ||
+ a.clientStatus === 'lost' ||
+ a.isUnhealthy
+ );
+ }
+
+ // eslint-disable-next-line require-yield
+ @task(function* () {
+ /**
+ * @typedef DeploymentSummary
+ * @property {string} id
+ * @property {boolean} isActive
+ * @property {string} jobVersion
+ * @property {string} status
+ * @property {string} statusDescription
+ * @property {boolean} allAutoPromote
+ * @property {boolean} requiresPromotion
+ */
+ /**
+ * @type {DeploymentSummary}
+ */
+ let latestDeploymentSummary = this.args.job.latestDeploymentSummary;
+
+ // Early return false if we don't have an active deployment
+ if (!latestDeploymentSummary.isActive) {
+ return false;
+ }
+
+ // Early return if we our deployment doesn't have any canaries
+ if (!this.args.job.hasActiveCanaries) {
+ console.log('!hasActiveCan');
+ return false;
+ }
+
+ if (latestDeploymentSummary.requiresPromotion) {
+ console.log('requires promotion, and...');
+ if (this.canariesHealthy) {
+ console.log('canaries are healthy.');
+ return 'canary-promote';
+ }
+
+ if (this.someCanariesHaveFailedAndWontReschedule) {
+ console.log('some canaries have failed.');
+ return 'canary-failure';
+ }
+ if (latestDeploymentSummary.allAutoPromote) {
+ console.log(
+ 'This deployment is set to auto-promote; canaries are being checked now'
+ );
+ // return "This deployment is set to auto-promote; canaries are being checked now";
+ return false;
+ } else {
+ console.log(
+ 'This deployment requires manual promotion and things are being checked now'
+ );
+ // return "This deployment requires manual promotion and things are being checked now";
+ return false;
+ }
+ }
+ return false;
+ })
+ requiresPromotionTask;
+
+ @task(function* () {
+ try {
+ yield this.args.job.latestDeployment.content.promote(); // TODO: need to do a deployment findRecord here first.
+ // dont bubble up
+ return false;
+ } catch (err) {
+ // TODO: handle error. add notifications.
+ console.log('caught error', err);
+ // this.handleError({
+ // title: 'Could Not Promote Deployment',
+ // // description: messageFromAdapterError(err, 'promote deployments'),
+ // });
- // One of independent, parent, or child. Used to customize the template
- // based on the relationship of this job to others.
- context = 'independent';
+ // err.errors.forEach((err) => {
+ // this.notifications.add({
+ // title: "Could not promote deployment",
+ // message: err.detail,
+ // color: 'critical',
+ // timeout: 8000,
+ // });
+ // });
+ }
+ })
+ promote;
- click(event) {
- lazyClick([this.gotoJob, event]);
+ get latestDeploymentFailed() {
+ return this.args.job.latestDeploymentSummary.status === 'failed';
}
@action
gotoJob() {
- const { job } = this;
+ const { job } = this.args;
this.router.transitionTo('jobs.job.index', job.idWithNamespace);
}
}
diff --git a/ui/app/components/job-search-box.hbs b/ui/app/components/job-search-box.hbs
new file mode 100644
index 000000000000..f6b20c2bf732
--- /dev/null
+++ b/ui/app/components/job-search-box.hbs
@@ -0,0 +1,20 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: BUSL-1.1
+~}}
+
+<@S.TextInput
+ @type="search"
+ @value={{@searchText}}
+ aria-label="Job Search"
+ placeholder="Name contains myJob"
+ @icon="search"
+ @width="300px"
+ {{on "input" (action this.updateSearchText)}}
+ {{keyboard-shortcut
+ label="Search Jobs"
+ pattern=(array "Shift+F")
+ action=(action this.focus)
+ }}
+ data-test-jobs-search
+/>
diff --git a/ui/app/components/job-search-box.js b/ui/app/components/job-search-box.js
new file mode 100644
index 000000000000..886f492aa744
--- /dev/null
+++ b/ui/app/components/job-search-box.js
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+// @ts-check
+
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { debounce } from '@ember/runloop';
+
+const DEBOUNCE_MS = 500;
+
+export default class JobSearchBoxComponent extends Component {
+ @service keyboard;
+
+ element = null;
+
+ @action
+ updateSearchText(event) {
+ debounce(this, this.sendUpdate, event.target.value, DEBOUNCE_MS);
+ }
+
+ sendUpdate(value) {
+ this.args.onSearchTextChange(value);
+ }
+
+ @action
+ focus(element) {
+ element.focus();
+ // Because the element is an input,
+ // and the "hide hints" part of our keynav implementation is on keyUp,
+ // but the focus action happens on keyDown,
+ // and the keynav explicitly ignores key input while focused in a text input,
+ // we need to manually hide the hints here.
+ this.keyboard.displayHints = false;
+ }
+}
diff --git a/ui/app/components/job-status/allocation-status-block.js b/ui/app/components/job-status/allocation-status-block.js
index 189014a9aaa5..f8866bad5f6f 100644
--- a/ui/app/components/job-status/allocation-status-block.js
+++ b/ui/app/components/job-status/allocation-status-block.js
@@ -7,6 +7,9 @@ import Component from '@glimmer/component';
export default class JobStatusAllocationStatusBlockComponent extends Component {
get countToShow() {
+ if (this.args.compact) {
+ return 0;
+ }
const restWidth = 50;
const restGap = 10;
let cts = Math.floor((this.args.width - (restWidth + restGap)) / 42);
diff --git a/ui/app/components/job-status/allocation-status-row.hbs b/ui/app/components/job-status/allocation-status-row.hbs
index d0c32344f162..1de96bb18c7a 100644
--- a/ui/app/components/job-status/allocation-status-row.hbs
+++ b/ui/app/components/job-status/allocation-status-row.hbs
@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
-
+
{{#if this.showSummaries}}
{{/if}}
{{/each-in}}
@@ -50,5 +54,8 @@
{{/each-in}}
{{/if}}
+ {{#if @compact}}
+ {{@runningAllocs}}/{{@groupCountSum}}
+ {{/if}}
diff --git a/ui/app/components/job-status/allocation-status-row.js b/ui/app/components/job-status/allocation-status-row.js
index 9f568adcaf1f..1e33cfe05dce 100644
--- a/ui/app/components/job-status/allocation-status-row.js
+++ b/ui/app/components/job-status/allocation-status-row.js
@@ -10,6 +10,7 @@ import { tracked } from '@glimmer/tracking';
const ALLOC_BLOCK_WIDTH = 32;
const ALLOC_BLOCK_GAP = 10;
+const COMPACT_INTER_SUMMARY_GAP = 7;
export default class JobStatusAllocationStatusRowComponent extends Component {
@tracked width = 0;
@@ -27,14 +28,33 @@ export default class JobStatusAllocationStatusRowComponent extends Component {
get showSummaries() {
return (
+ this.args.compact ||
this.allocBlockSlots * (ALLOC_BLOCK_WIDTH + ALLOC_BLOCK_GAP) -
ALLOC_BLOCK_GAP >
- this.width
+ this.width
);
}
+ // When we calculate how much width to give to a row in our viz,
+ // we want to also offset the gap BETWEEN summaries. The way that css grid
+ // works, a gap only appears between 2 elements, not at the start or end of a row.
+ // Thus, we need to calculate total gap space using the number of summaries shown.
+ get numberOfSummariesShown() {
+ return Object.values(this.args.allocBlocks)
+ .flatMap((statusObj) => Object.values(statusObj))
+ .flatMap((healthObj) => Object.values(healthObj))
+ .filter((allocs) => allocs.length > 0).length;
+ }
+
calcPerc(count) {
- return (count / this.allocBlockSlots) * this.width;
+ if (this.args.compact) {
+ const totalGaps =
+ (this.numberOfSummariesShown - 1) * COMPACT_INTER_SUMMARY_GAP;
+ const usableWidth = this.width - totalGaps;
+ return (count / this.allocBlockSlots) * usableWidth;
+ } else {
+ return (count / this.allocBlockSlots) * this.width;
+ }
}
@action reflow(element) {
diff --git a/ui/app/components/job-status/panel/steady.js b/ui/app/components/job-status/panel/steady.js
index 9e6f9c40d517..ac03654b7dd6 100644
--- a/ui/app/components/job-status/panel/steady.js
+++ b/ui/app/components/job-status/panel/steady.js
@@ -12,11 +12,7 @@ export default class JobStatusPanelSteadyComponent extends Component {
@alias('args.job') job;
get allocTypes() {
- return jobAllocStatuses[this.args.job.type].map((type) => {
- return {
- label: type,
- };
- });
+ return this.args.job.allocTypes;
}
/**
@@ -143,7 +139,8 @@ export default class JobStatusPanelSteadyComponent extends Component {
if (this.args.job.type === 'service' || this.args.job.type === 'batch') {
return this.args.job.taskGroups.reduce((sum, tg) => sum + tg.count, 0);
} else if (this.atMostOneAllocPerNode) {
- return this.args.job.allocations.uniqBy('nodeID').length;
+ return this.args.job.allocations.filterBy('nodeID').uniqBy('nodeID')
+ .length;
} else {
return this.args.job.count; // TODO: this is probably not the correct totalAllocs count for any type.
}
diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js
index a095e5154fcd..106a6e94bd4d 100644
--- a/ui/app/controllers/jobs/index.js
+++ b/ui/app/controllers/jobs/index.js
@@ -3,355 +3,554 @@
* SPDX-License-Identifier: BUSL-1.1
*/
-//@ts-check
+// @ts-check
-/* eslint-disable ember/no-incorrect-calls-with-inline-anonymous-functions */
-import { inject as service } from '@ember/service';
-import { alias, readOnly } from '@ember/object/computed';
import Controller from '@ember/controller';
-import { computed, action } from '@ember/object';
-import { scheduleOnce } from '@ember/runloop';
-import intersection from 'lodash.intersection';
-import Sortable from 'nomad-ui/mixins/sortable';
-import Searchable from 'nomad-ui/mixins/searchable';
-import {
- serialize,
- deserializedQueryParam as selection,
-} from 'nomad-ui/utils/qp-serialize';
-import classic from 'ember-classic-decorator';
-
-const DEFAULT_SORT_PROPERTY = 'modifyIndex';
-const DEFAULT_SORT_DESCENDING = true;
-
-@classic
-export default class IndexController extends Controller.extend(
- Sortable,
- Searchable
-) {
+import { inject as service } from '@ember/service';
+import { action, computed, set } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
+import { restartableTask, timeout } from 'ember-concurrency';
+import Ember from 'ember';
+
+const JOB_LIST_THROTTLE = 5000;
+const JOB_DETAILS_THROTTLE = 1000;
+
+export default class JobsIndexController extends Controller {
+ @service router;
@service system;
+ @service store;
@service userSettings;
- @service router;
+ @service watchList;
- isForbidden = false;
+ @tracked pageSize;
+
+ constructor() {
+ super(...arguments);
+ this.pageSize = this.userSettings.pageSize;
+ }
queryParams = [
- {
- currentPage: 'page',
- },
- {
- searchTerm: 'search',
- },
- {
- sortProperty: 'sort',
- },
- {
- sortDescending: 'desc',
- },
- {
- qpType: 'type',
- },
- {
- qpStatus: 'status',
- },
- {
- qpDatacenter: 'dc',
- },
- {
- qpPrefix: 'prefix',
- },
- {
- qpNamespace: 'namespace',
- },
- {
- qpNodePool: 'nodePool',
- },
+ 'cursorAt',
+ 'pageSize',
+ { qpNamespace: 'namespace' },
+ 'filter',
];
- qpNamespace = '*';
+ isForbidden = false;
- currentPage = 1;
- @readOnly('userSettings.pageSize') pageSize;
+ @tracked jobQueryIndex = 0;
+ @tracked jobAllocsQueryIndex = 0;
- sortProperty = DEFAULT_SORT_PROPERTY;
- sortDescending = DEFAULT_SORT_DESCENDING;
+ @tracked qpNamespace = '*';
- @computed
- get searchProps() {
- return ['id', 'name'];
+ get tableColumns() {
+ return [
+ 'name',
+ this.system.shouldShowNamespaces ? 'namespace' : null,
+ 'status',
+ 'type',
+ this.system.shouldShowNodepools ? 'node pool' : null, // TODO: implement on system service
+ 'running allocations',
+ ]
+ .filter((c) => !!c)
+ .map((c) => {
+ return {
+ label: c.charAt(0).toUpperCase() + c.slice(1),
+ width: c === 'running allocations' ? '200px' : undefined,
+ };
+ });
}
- @computed
- get fuzzySearchProps() {
- return ['name'];
- }
+ @tracked jobs = [];
+ @tracked jobIDs = [];
+ @tracked pendingJobs = null;
+ @tracked pendingJobIDs = null;
- fuzzySearchEnabled = true;
+ @action
+ gotoJob(job) {
+ this.router.transitionTo('jobs.job.index', job.idWithNamespace);
+ }
- qpType = '';
- qpStatus = '';
- qpDatacenter = '';
- qpPrefix = '';
- qpNodePool = '';
+ @action
+ goToRun() {
+ this.router.transitionTo('jobs.run');
+ }
- @selection('qpType') selectionType;
- @selection('qpStatus') selectionStatus;
- @selection('qpDatacenter') selectionDatacenter;
- @selection('qpPrefix') selectionPrefix;
- @selection('qpNodePool') selectionNodePool;
+ // #region pagination
+ @tracked cursorAt;
+ @tracked nextToken; // route sets this when new data is fetched
- @computed
- get optionsType() {
- return [
- { key: 'batch', label: 'Batch' },
- { key: 'pack', label: 'Pack' },
- { key: 'parameterized', label: 'Parameterized' },
- { key: 'periodic', label: 'Periodic' },
- { key: 'service', label: 'Service' },
- { key: 'system', label: 'System' },
- { key: 'sysbatch', label: 'System Batch' },
- ];
+ /**
+ *
+ * @param {"prev"|"next"} page
+ */
+ @action async handlePageChange(page) {
+ // reset indexes
+ this.jobQueryIndex = 0;
+ this.jobAllocsQueryIndex = 0;
+
+ if (page === 'prev') {
+ if (!this.cursorAt) {
+ return;
+ }
+ // Note (and TODO:) this isn't particularly efficient!
+ // We're making an extra full request to get the nextToken we need,
+ // but actually the results of that request are the reverse order, plus one job,
+ // of what we actually want to show on the page!
+ // I should investigate whether I can use the results of this query to
+ // overwrite this controller's jobIDs, leverage its index, and
+ // restart a blocking watchJobIDs here.
+ let prevPageToken = await this.loadPreviousPageToken();
+ // If there's no nextToken, we're at the "start" of our list and can drop the cursorAt
+ if (!prevPageToken.meta.nextToken) {
+ this.cursorAt = undefined;
+ } else {
+ // cursorAt should be the highest modifyIndex from the previous query.
+ // This will immediately fire the route model hook with the new cursorAt
+ this.cursorAt = prevPageToken
+ .sortBy('modifyIndex')
+ .get('lastObject').modifyIndex;
+ }
+ } else if (page === 'next') {
+ if (!this.nextToken) {
+ return;
+ }
+ this.cursorAt = this.nextToken;
+ } else if (page === 'first') {
+ this.cursorAt = undefined;
+ } else if (page === 'last') {
+ let prevPageToken = await this.loadPreviousPageToken({ last: true });
+ this.cursorAt = prevPageToken
+ .sortBy('modifyIndex')
+ .get('lastObject').modifyIndex;
+ }
}
- @computed
- get optionsStatus() {
- return [
- { key: 'pending', label: 'Pending' },
- { key: 'running', label: 'Running' },
- { key: 'dead', label: 'Dead' },
- ];
+ @action handlePageSizeChange(size) {
+ this.pageSize = size;
}
- @computed('selectionDatacenter', 'visibleJobs.[]')
- get optionsDatacenter() {
- const flatten = (acc, val) => acc.concat(val);
- const allDatacenters = new Set(
- this.visibleJobs.mapBy('datacenters').reduce(flatten, [])
+ get pendingJobIDDiff() {
+ return (
+ this.pendingJobIDs &&
+ JSON.stringify(
+ this.pendingJobIDs.map((j) => `${j.namespace}.${j.id}`)
+ ) !== JSON.stringify(this.jobIDs.map((j) => `${j.namespace}.${j.id}`))
);
+ }
- // Remove any invalid datacenters from the query param/selection
- const availableDatacenters = Array.from(allDatacenters).compact();
- scheduleOnce('actions', () => {
- // eslint-disable-next-line ember/no-side-effects
- this.set(
- 'qpDatacenter',
- serialize(intersection(availableDatacenters, this.selectionDatacenter))
- );
- });
-
- return availableDatacenters.sort().map((dc) => ({ key: dc, label: dc }));
+ /**
+ * Manually, on click, update jobs from pendingJobs
+ * when live updates are disabled (via nomadLiveUpdateJobsIndex)
+ */
+ @restartableTask *updateJobList() {
+ this.jobs = this.pendingJobs;
+ this.pendingJobs = null;
+ this.jobIDs = this.pendingJobIDs;
+ this.pendingJobIDs = null;
+ yield this.watchJobs.perform(
+ this.jobIDs,
+ Ember.testing ? 0 : JOB_DETAILS_THROTTLE
+ );
}
- @computed('selectionPrefix', 'visibleJobs.[]')
- get optionsPrefix() {
- // A prefix is defined as the start of a job name up to the first - or .
- // ex: mktg-analytics -> mktg, ds.supermodel.classifier -> ds
- const hasPrefix = /.[-._]/;
-
- // Collect and count all the prefixes
- const allNames = this.visibleJobs.mapBy('name');
- const nameHistogram = allNames.reduce((hist, name) => {
- if (hasPrefix.test(name)) {
- const prefix = name.match(/(.+?)[-._]/)[1];
- hist[prefix] = hist[prefix] ? hist[prefix] + 1 : 1;
- }
- return hist;
- }, {});
-
- // Convert to an array
- const nameTable = Object.keys(nameHistogram).map((key) => ({
- prefix: key,
- count: nameHistogram[key],
- }));
-
- // Only consider prefixes that match more than one name
- const prefixes = nameTable.filter((name) => name.count > 1);
-
- // Remove any invalid prefixes from the query param/selection
- const availablePrefixes = prefixes.mapBy('prefix');
- scheduleOnce('actions', () => {
- // eslint-disable-next-line ember/no-side-effects
- this.set(
- 'qpPrefix',
- serialize(intersection(availablePrefixes, this.selectionPrefix))
- );
- });
+ @localStorageProperty('nomadLiveUpdateJobsIndex', true) liveUpdatesEnabled;
- // Sort, format, and include the count in the label
- return prefixes.sortBy('prefix').map((name) => ({
- key: name.prefix,
- label: `${name.prefix} (${name.count})`,
- }));
- }
+ // #endregion pagination
- @computed('qpNamespace', 'model.namespaces.[]')
- get optionsNamespaces() {
- const availableNamespaces = this.model.namespaces.map((namespace) => ({
- key: namespace.name,
- label: namespace.name,
- }));
+ //#region querying
- availableNamespaces.unshift({
- key: '*',
- label: 'All (*)',
- });
+ jobQuery(params) {
+ this.watchList.jobsIndexIDsController.abort();
+ this.watchList.jobsIndexIDsController = new AbortController();
- // Unset the namespace selection if it was server-side deleted
- if (
- this.qpNamespace &&
- !availableNamespaces.mapBy('key').includes(this.qpNamespace)
- ) {
- scheduleOnce('actions', () => {
- // eslint-disable-next-line ember/no-side-effects
- this.set('qpNamespace', '*');
+ return this.store
+ .query('job', params, {
+ adapterOptions: {
+ abortController: this.watchList.jobsIndexIDsController,
+ },
+ })
+ .catch((e) => {
+ if (e.name !== 'AbortError') {
+ console.log('error fetching job ids', e);
+ }
+ return;
});
- }
-
- return availableNamespaces;
}
- @computed('selectionNodePool', 'model.nodePools.[]')
- get optionsNodePool() {
- const availableNodePools = this.model.nodePools;
-
- scheduleOnce('actions', () => {
- // eslint-disable-next-line ember/no-side-effects
- this.set(
- 'qpNodePool',
- serialize(
- intersection(
- availableNodePools.map(({ name }) => name),
- this.selectionNodePool
- )
- )
- );
- });
-
- return availableNodePools.map((nodePool) => ({
- key: nodePool.name,
- label: nodePool.name,
- }));
+ jobAllocsQuery(params) {
+ // TODO: Noticing a pattern with long-running jobs with alloc changes, where there are multiple POST statuses blocking queries being held open at once.
+ // This is a problem and I should get to the bottom of it.
+ this.watchList.jobsIndexDetailsController.abort();
+ this.watchList.jobsIndexDetailsController = new AbortController();
+ params.namespace = '*';
+ return this.store
+ .query('job', params, {
+ adapterOptions: {
+ method: 'POST',
+ abortController: this.watchList.jobsIndexDetailsController,
+ },
+ })
+ .catch((e) => {
+ if (e.name !== 'AbortError') {
+ console.log('error fetching job allocs', e);
+ }
+ return;
+ });
}
- /**
- Visible jobs are those that match the selected namespace and aren't children
- of periodic or parameterized jobs.
- */
- @computed('model.jobs.@each.parent')
- get visibleJobs() {
- if (!this.model || !this.model.jobs) return [];
- return this.model.jobs
- .compact()
- .filter((job) => !job.isNew)
- .filter((job) => !job.get('parent.content'));
+ // Ask for the previous #page_size jobs, starting at the first job that's currently shown
+ // on our page, and the last one in our list should be the one we use for our
+ // subsequent nextToken.
+ async loadPreviousPageToken({ last = false } = {}) {
+ let next_token = +this.cursorAt + 1;
+ if (last) {
+ next_token = undefined;
+ }
+ let prevPageToken = await this.store.query(
+ 'job',
+ {
+ next_token,
+ per_page: this.pageSize,
+ reverse: true,
+ },
+ {
+ adapterOptions: {
+ method: 'GET',
+ },
+ }
+ );
+ return prevPageToken;
}
- @computed(
- 'visibleJobs.[]',
- 'selectionType',
- 'selectionStatus',
- 'selectionDatacenter',
- 'selectionNodePool',
- 'selectionPrefix'
- )
- get filteredJobs() {
- const {
- selectionType: types,
- selectionStatus: statuses,
- selectionDatacenter: datacenters,
- selectionPrefix: prefixes,
- selectionNodePool: nodePools,
- } = this;
-
- // A job must match ALL filter facets, but it can match ANY selection within a facet
- // Always return early to prevent unnecessary facet predicates.
- return this.visibleJobs.filter((job) => {
- const shouldShowPack = types.includes('pack') && job.displayType.isPack;
-
- if (types.length && shouldShowPack) {
- return true;
+ @restartableTask *watchJobIDs(
+ params,
+ throttle = Ember.testing ? 0 : JOB_LIST_THROTTLE
+ ) {
+ while (true) {
+ let currentParams = params;
+ currentParams.index = this.jobQueryIndex;
+ const newJobs = yield this.jobQuery(currentParams, {});
+ if (newJobs) {
+ if (newJobs.meta.index) {
+ this.jobQueryIndex = newJobs.meta.index;
+ }
+ if (newJobs.meta.nextToken) {
+ this.nextToken = newJobs.meta.nextToken;
+ } else {
+ this.nextToken = null;
+ }
+
+ const jobIDs = newJobs.map((job) => ({
+ id: job.plainId,
+ namespace: job.belongsTo('namespace').id(),
+ }));
+
+ const okayToJostle = this.liveUpdatesEnabled;
+ if (okayToJostle) {
+ this.jobIDs = jobIDs;
+ this.watchList.jobsIndexDetailsController.abort();
+ this.jobAllocsQueryIndex = 0;
+ this.watchList.jobsIndexDetailsController = new AbortController();
+ this.watchJobs.perform(jobIDs, throttle);
+ } else {
+ this.pendingJobIDs = jobIDs;
+ this.pendingJobs = newJobs;
+ }
+ yield timeout(throttle);
+ } else {
+ // This returns undefined on page change / cursorAt change, resulting from the aborting of the old query.
+ yield timeout(throttle);
+ this.watchJobs.perform(this.jobIDs, throttle);
+ continue;
}
-
- if (types.length && !types.includes(job.get('displayType.type'))) {
- return false;
+ if (Ember.testing) {
+ break;
}
+ }
+ }
- if (statuses.length && !statuses.includes(job.get('status'))) {
- return false;
+ // Called in 3 ways:
+ // 1. via the setupController of the jobs index route's model
+ // (which can happen both on initial load, and should the queryParams change)
+ // 2. via the watchJobIDs task seeing new jobIDs
+ // 3. via the user manually clicking to updateJobList()
+ @restartableTask *watchJobs(
+ jobIDs,
+ throttle = Ember.testing ? 0 : JOB_DETAILS_THROTTLE
+ ) {
+ while (true) {
+ if (jobIDs && jobIDs.length > 0) {
+ let jobDetails = yield this.jobAllocsQuery({
+ jobs: jobIDs,
+ index: this.jobAllocsQueryIndex,
+ });
+ if (jobDetails) {
+ if (jobDetails.meta.index) {
+ this.jobAllocsQueryIndex = jobDetails.meta.index;
+ }
+ }
+ this.jobs = jobDetails;
+ } else {
+ // No jobs have returned, so clear the list
+ this.jobs = [];
}
-
- if (
- datacenters.length &&
- !job.get('datacenters').find((dc) => datacenters.includes(dc))
- ) {
- return false;
+ yield timeout(throttle);
+ if (Ember.testing) {
+ break;
}
+ }
+ }
+ //#endregion querying
+
+ //#region filtering and searching
+
+ @tracked statusFacet = {
+ label: 'Status',
+ options: [
+ {
+ key: 'pending',
+ string: 'Status == pending',
+ checked: false,
+ },
+ {
+ key: 'running',
+ string: 'Status == running',
+ checked: false,
+ },
+ {
+ key: 'dead',
+ string: 'Status == dead',
+ checked: false,
+ },
+ ],
+ };
+
+ @tracked typeFacet = {
+ label: 'Type',
+ options: [
+ {
+ key: 'batch',
+ string: 'Type == batch',
+ checked: false,
+ },
+ {
+ key: 'service',
+ string: 'Type == service',
+ checked: false,
+ },
+ {
+ key: 'system',
+ string: 'Type == system',
+ checked: false,
+ },
+ {
+ key: 'sysbatch',
+ string: 'Type == sysbatch',
+ checked: false,
+ },
+ ],
+ };
+
+ @tracked nodePoolFacet = {
+ label: 'NodePool',
+ options: (this.model.nodePools || []).map((nodePool) => ({
+ key: nodePool.name,
+ string: `NodePool == ${nodePool.name}`,
+ checked: false,
+ })),
+ };
+
+ @computed('system.shouldShowNamespaces', 'model.namespaces.[]', 'qpNamespace')
+ get namespaceFacet() {
+ if (!this.system.shouldShowNamespaces) {
+ return null;
+ }
- if (nodePools.length && !nodePools.includes(job.get('nodePool'))) {
- return false;
- }
+ const availableNamespaces = (this.model.namespaces || []).map(
+ (namespace) => ({
+ key: namespace.name,
+ label: namespace.name,
+ })
+ );
- const name = job.get('name');
- if (
- prefixes.length &&
- !prefixes.find((prefix) => name.startsWith(prefix))
- ) {
- return false;
- }
+ availableNamespaces.unshift({
+ key: '*',
+ label: 'All',
+ });
- return true;
+ let selectedNamespaces = this.qpNamespace || '*';
+ availableNamespaces.forEach((opt) => {
+ if (selectedNamespaces.includes(opt.key)) {
+ opt.checked = true;
+ }
});
- }
- // eslint-disable-next-line ember/require-computed-property-dependencies
- @computed('searchTerm')
- get sortAtLastSearch() {
return {
- sortProperty: this.sortProperty,
- sortDescending: this.sortDescending,
- searchTerm: this.searchTerm,
+ label: 'Namespace',
+ options: availableNamespaces,
};
}
+ get filterFacets() {
+ let facets = [this.statusFacet, this.typeFacet];
+ if (this.system.shouldShowNodepools) {
+ facets.push(this.nodePoolFacet);
+ }
+ return facets;
+ }
+
+ /**
+ * On page load, takes the ?filter queryParam, and extracts it into those
+ * properties used by the dropdown filter toggles, and the search text.
+ */
+ parseFilter() {
+ let filterString = this.filter;
+ if (!filterString) {
+ return;
+ }
+
+ const filterParts = filterString.split(' and ');
+
+ let unmatchedFilters = [];
+
+ // For each of those splits, if it starts and ends with (), and if all entries within it have thes ame Propname and operator of ==, populate them into the appropriate dropdown
+ // If it doesnt start with and end with (), or if it does but not all entries are the same propname, or not all entries have == operators, populate them into the searchbox
+
+ filterParts.forEach((part) => {
+ let matched = false;
+ if (part.startsWith('(') && part.endsWith(')')) {
+ part = part.slice(1, -1); // trim the parens
+ // Check to see if the property name (first word) is one of the ones for which we have a dropdown
+ let propName = part.split(' ')[0];
+ if (this.filterFacets.find((facet) => facet.label === propName)) {
+ // Split along "or" and check that all parts have the same propName
+ let facetParts = part.split(' or ');
+ let allMatch = facetParts.every((facetPart) =>
+ facetPart.startsWith(propName)
+ );
+ let allEqualityOperators = facetParts.every((facetPart) =>
+ facetPart.includes('==')
+ );
+ if (allMatch && allEqualityOperators) {
+ // Set all the options in the dropdown to checked
+ this.filterFacets.forEach((group) => {
+ if (group.label === propName) {
+ group.options.forEach((option) => {
+ set(option, 'checked', facetParts.includes(option.string));
+ });
+ }
+ });
+ matched = true;
+ }
+ }
+ }
+ if (!matched) {
+ unmatchedFilters.push(part);
+ }
+ });
+
+ // Combine all unmatched filter parts into the searchText
+ this.searchText = unmatchedFilters.join(' and ');
+ }
+
@computed(
- 'searchTerm',
- 'sortAtLastSearch.{sortDescending,sortProperty}',
- 'sortDescending',
- 'sortProperty'
+ 'filterFacets',
+ 'nodePoolFacet.options.@each.checked',
+ 'searchText',
+ 'statusFacet.options.@each.checked',
+ 'typeFacet.options.@each.checked'
)
- get prioritizeSearchOrder() {
- let shouldPrioritizeSearchOrder =
- !!this.searchTerm &&
- this.sortAtLastSearch.sortProperty === this.sortProperty &&
- this.sortAtLastSearch.sortDescending === this.sortDescending;
- if (shouldPrioritizeSearchOrder) {
- /* eslint-disable ember/no-side-effects */
- this.set('sortDescending', DEFAULT_SORT_DESCENDING);
- this.set('sortProperty', DEFAULT_SORT_PROPERTY);
- this.set('sortAtLastSearch.sortProperty', DEFAULT_SORT_PROPERTY);
- this.set('sortAtLastSearch.sortDescending', DEFAULT_SORT_DESCENDING);
- }
- /* eslint-enable ember/no-side-effects */
- return shouldPrioritizeSearchOrder;
+ get computedFilter() {
+ let parts = this.searchText ? [this.searchText] : [];
+ this.filterFacets.forEach((group) => {
+ let groupParts = [];
+ group.options.forEach((option) => {
+ if (option.checked) {
+ groupParts.push(option.string);
+ }
+ });
+ if (groupParts.length) {
+ parts.push(`(${groupParts.join(' or ')})`);
+ }
+ });
+ return parts.join(' and ');
}
- @alias('filteredJobs') listToSearch;
- @alias('listSearched') listToSort;
+ @action
+ toggleOption(option) {
+ set(option, 'checked', !option.checked);
+ this.updateFilter();
+ }
+
+ // Radio button set
+ @action
+ toggleNamespaceOption(option, dropdown) {
+ this.qpNamespace = option.key;
+ dropdown.close();
+ }
- // sortedJobs is what we use to populate the table;
- // If the user has searched but not sorted, we return the (fuzzy) searched list verbatim
- // If the user has sorted, we allow the fuzzy search to filter down the list, but return it in a sorted order.
- get sortedJobs() {
- return this.prioritizeSearchOrder ? this.listSearched : this.listSorted;
+ @action
+ updateFilter() {
+ this.cursorAt = null;
+ this.filter = this.computedFilter;
}
- isShowingDeploymentDetails = false;
+ @tracked filter = '';
+ @tracked searchText = '';
- setFacetQueryParam(queryParam, selection) {
- this.set(queryParam, serialize(selection));
+ @action resetFilters() {
+ this.searchText = '';
+ this.filterFacets.forEach((group) => {
+ group.options.forEach((option) => {
+ set(option, 'checked', false);
+ });
+ });
+ this.qpNamespace = '*';
+ this.updateFilter();
}
+ /**
+ * Updates the filter based on the input, distinguishing between simple job names and filter expressions.
+ * A simple check for operators with surrounding spaces is used to identify filter expressions.
+ *
+ * @param {string} newFilter
+ */
@action
- goToRun() {
- this.router.transitionTo('jobs.run');
+ updateSearchText(newFilter) {
+ if (!newFilter.trim()) {
+ this.searchText = '';
+ return;
+ }
+
+ newFilter = newFilter.trim();
+
+ const operators = [
+ '==',
+ '!=',
+ 'contains',
+ 'not contains',
+ 'is empty',
+ 'is not empty',
+ 'matches',
+ 'not matches',
+ 'in',
+ 'not in',
+ ];
+
+ // Check for any operator surrounded by spaces
+ let isFilterExpression = operators.some((op) =>
+ newFilter.includes(` ${op} `)
+ );
+
+ if (isFilterExpression) {
+ this.searchText = newFilter;
+ } else {
+ // If it's a string without a filter operator, assume the user is trying to look up a job name
+ this.searchText = `Name contains ${newFilter}`;
+ }
}
+
+ //#endregion filtering and searching
}
diff --git a/ui/app/controllers/settings/tokens.js b/ui/app/controllers/settings/tokens.js
index 74e2728f3d2a..7d679606e288 100644
--- a/ui/app/controllers/settings/tokens.js
+++ b/ui/app/controllers/settings/tokens.js
@@ -12,6 +12,7 @@ import { action } from '@ember/object';
import classic from 'ember-classic-decorator';
import { tracked } from '@glimmer/tracking';
import Ember from 'ember';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
/**
* @type {RegExp}
@@ -279,4 +280,9 @@ export default class Tokens extends Controller {
get shouldShowPolicies() {
return this.tokenRecord;
}
+
+ // #region settings
+ @localStorageProperty('nomadShouldWrapCode', false) wordWrap;
+ @localStorageProperty('nomadLiveUpdateJobsIndex', true) liveUpdateJobsIndex;
+ // #endregion settings
}
diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js
index 4d83507a6ff4..d1a0cec7f9dd 100644
--- a/ui/app/models/allocation.js
+++ b/ui/app/models/allocation.js
@@ -71,12 +71,12 @@ export default class Allocation extends Model {
return (
this.willNotRestart &&
!this.get('nextAllocation.content') &&
- !this.get('followUpEvaluation.content')
+ !this.belongsTo('followUpEvaluation').id()
);
}
get hasBeenRescheduled() {
- return this.get('followUpEvaluation.content');
+ return Boolean(this.belongsTo('followUpEvaluation').id());
}
get hasBeenRestarted() {
@@ -131,7 +131,7 @@ export default class Allocation extends Model {
preemptedByAllocation;
@attr('boolean') wasPreempted;
- @belongsTo('evaluation') followUpEvaluation;
+ @belongsTo('evaluation', { async: true }) followUpEvaluation;
@computed('clientStatus')
get statusClass() {
diff --git a/ui/app/models/job.js b/ui/app/models/job.js
index b873c0a51993..06eae95950f0 100644
--- a/ui/app/models/job.js
+++ b/ui/app/models/job.js
@@ -3,6 +3,8 @@
* SPDX-License-Identifier: BUSL-1.1
*/
+// @ts-check
+
import { alias, equal, or, and, mapBy } from '@ember/object/computed';
import { computed } from '@ember/object';
import Model from '@ember-data/model';
@@ -11,6 +13,7 @@ import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
import RSVP from 'rsvp';
import { assert } from '@ember/debug';
import classic from 'ember-classic-decorator';
+import { jobAllocStatuses } from '../utils/allocation-client-statuses';
const JOB_TYPES = ['service', 'batch', 'system', 'sysbatch'];
@@ -30,6 +33,367 @@ export default class Job extends Model {
@attr('date') submitTime;
@attr('string') nodePool; // Jobs are related to Node Pools either directly or via its Namespace, but no relationship.
+ @attr('number') groupCountSum;
+ // if it's a system/sysbatch job, groupCountSum is allocs uniqued by nodeID
+ get expectedRunningAllocCount() {
+ if (this.type === 'system' || this.type === 'sysbatch') {
+ return this.allocations.filterBy('nodeID').uniqBy('nodeID').length;
+ } else {
+ return this.groupCountSum;
+ }
+ }
+
+ /**
+ * @typedef {Object} LatestDeploymentSummary
+ * @property {boolean} IsActive - Whether the deployment is currently active
+ * @property {number} JobVersion - The version of the job that was deployed
+ * @property {string} Status - The status of the deployment
+ * @property {string} StatusDescription - A description of the deployment status
+ * @property {boolean} AllAutoPromote - Whether all allocations were auto-promoted
+ * @property {boolean} RequiresPromotion - Whether the deployment requires promotion
+ */
+ @attr({ defaultValue: () => ({}) }) latestDeploymentSummary;
+
+ get hasActiveCanaries() {
+ if (!this.latestDeploymentSummary.isActive) {
+ return false;
+ }
+ return Object.keys(this.allocBlocks)
+ .map((status) => {
+ return Object.keys(this.allocBlocks[status])
+ .map((health) => {
+ return this.allocBlocks[status][health].canary.length;
+ })
+ .flat();
+ })
+ .flat()
+ .any((n) => !!n);
+ }
+
+ @attr() childStatuses;
+
+ get childStatusBreakdown() {
+ // child statuses is something like ['dead', 'dead', 'complete', 'running', 'running', 'dead'].
+ // Return an object counting by status, like {dead: 3, complete: 1, running: 2}
+ const breakdown = {};
+ this.childStatuses.forEach((status) => {
+ if (breakdown[status]) {
+ breakdown[status]++;
+ } else {
+ breakdown[status] = 1;
+ }
+ });
+ return breakdown;
+ }
+
+ // When we detect the deletion/purge of a job from within that job page, we kick the user out to the jobs index.
+ // But what about when that purge is detected from the jobs index?
+ // We set this flag to true to let the user know that the job has been removed without simply nixing it from view.
+ @attr('boolean', { defaultValue: false }) assumeGC;
+
+ /**
+ * @returns {Array<{label: string}>}
+ */
+ get allocTypes() {
+ return jobAllocStatuses[this.type].map((type) => {
+ return {
+ label: type,
+ };
+ });
+ }
+
+ /**
+ * @typedef {Object} CurrentStatus
+ * @property {"Healthy"|"Failed"|"Deploying"|"Degraded"|"Recovering"|"Complete"|"Running"|"Removed"} label - The current status of the job
+ * @property {"highlight"|"success"|"warning"|"critical"|"neutral"} state -
+ */
+
+ /**
+ * @typedef {Object} HealthStatus
+ * @property {Array} nonCanary
+ * @property {Array} canary
+ */
+
+ /**
+ * @typedef {Object} AllocationStatus
+ * @property {HealthStatus} healthy
+ * @property {HealthStatus} unhealthy
+ * @property {HealthStatus} health unknown
+ */
+
+ /**
+ * @typedef {Object} AllocationBlock
+ * @property {AllocationStatus} [running]
+ * @property {AllocationStatus} [pending]
+ * @property {AllocationStatus} [failed]
+ * @property {AllocationStatus} [lost]
+ * @property {AllocationStatus} [unplaced]
+ * @property {AllocationStatus} [complete]
+ */
+
+ /**
+ * Looks through running/pending allocations with the aim of filling up your desired number of allocations.
+ * If any desired remain, it will walk backwards through job versions and other allocation types to build
+ * a picture of the job's overall status.
+ *
+ * @returns {AllocationBlock} An object containing healthy non-canary allocations
+ * for each clientStatus.
+ */
+ get allocBlocks() {
+ let availableSlotsToFill = this.expectedRunningAllocCount;
+
+ let isDeploying = this.latestDeploymentSummary.isActive;
+ // Initialize allocationsOfShowableType with empty arrays for each clientStatus
+ /**
+ * @type {AllocationBlock}
+ */
+ let allocationsOfShowableType = this.allocTypes.reduce(
+ (categories, type) => {
+ categories[type.label] = {
+ healthy: { canary: [], nonCanary: [] },
+ unhealthy: { canary: [], nonCanary: [] },
+ health_unknown: { canary: [], nonCanary: [] },
+ };
+ return categories;
+ },
+ {}
+ );
+
+ if (isDeploying) {
+ // Start with just the new-version allocs
+ let allocationsOfDeploymentVersion = this.allocations.filter(
+ (a) => !a.isOld
+ );
+ // For each of them, check to see if we still have slots to fill, based on our desired Count
+ for (let alloc of allocationsOfDeploymentVersion) {
+ if (availableSlotsToFill <= 0) {
+ break;
+ }
+ let status = alloc.clientStatus;
+ let canary = alloc.isCanary ? 'canary' : 'nonCanary';
+ // TODO: do I need to dig into alloc.DeploymentStatus for these?
+
+ // Health status only matters in the context of a "running" allocation.
+ // However, healthy/unhealthy is never purged when an allocation moves to a different clientStatus
+ // Thus, we should only show something as "healthy" in the event that it is running.
+ // Otherwise, we'd have arbitrary groupings based on previous health status.
+ let health;
+
+ if (status === 'running') {
+ if (alloc.isHealthy) {
+ health = 'healthy';
+ } else if (alloc.isUnhealthy) {
+ health = 'unhealthy';
+ } else {
+ health = 'health_unknown';
+ }
+ } else {
+ health = 'health_unknown';
+ }
+
+ if (allocationsOfShowableType[status]) {
+ // If status is failed or lost, we only want to show it IF it's used up its restarts/rescheds.
+ // Otherwise, we'd be showing an alloc that had been replaced.
+
+ // TODO: We can't know about .willNotRestart and .willNotReschedule here, as we don't have access to alloc.followUpEvaluation.
+ // in deploying.js' newVersionAllocBlocks, we can know to ignore a canary if it has been rescheduled by virtue of seeing its .hasBeenRescheduled,
+ // which checks allocation.followUpEvaluation. This is not currently possible here.
+ // As such, we should count our running/pending canaries first, and if our expected count is still not filled, we can look to failed canaries.
+ // The goal of this is that any failed allocation that gets rescheduled would first have its place in relevantAllocs eaten up by a running/pending allocation,
+ // leaving it on the outside of what the user sees.
+
+ // ^--- actually, scratch this. We should just get alloc.FollowupEvalID. If it's not null, we can assume it's been rescheduled.
+
+ if (alloc.willNotRestart) {
+ if (!alloc.willNotReschedule) {
+ // Dont count it
+ continue;
+ }
+ }
+ allocationsOfShowableType[status][health][canary].push(alloc);
+ availableSlotsToFill--;
+ }
+ }
+ } else {
+ // First accumulate the Running/Pending allocations
+ for (const alloc of this.allocations.filter(
+ (a) => a.clientStatus === 'running' || a.clientStatus === 'pending'
+ )) {
+ if (availableSlotsToFill === 0) {
+ break;
+ }
+
+ const status = alloc.clientStatus;
+ // console.log('else and pushing with', status, 'and', alloc);
+ // We are not actively deploying in this condition,
+ // so we can assume Healthy and Non-Canary
+ allocationsOfShowableType[status].healthy.nonCanary.push(alloc);
+ availableSlotsToFill--;
+ }
+ // TODO: return early here if !availableSlotsToFill
+
+ // So, we've tried filling our desired Count with running/pending allocs.
+ // If we still have some slots remaining, we should sort our other allocations
+ // by version number, descending, and then by status order (arbitrary, via allocation-client-statuses.js).
+ let sortedAllocs;
+
+ // Sort all allocs by jobVersion in descending order
+ sortedAllocs = this.allocations
+ .filter(
+ (a) => a.clientStatus !== 'running' && a.clientStatus !== 'pending'
+ )
+ .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
+ // For example, we may have some allocBlock slots to fill, and need to determine
+ // if the user expects to see, from non-running/non-pending allocs, some old "failed" ones
+ // or "lost" or "complete" ones, etc. jobAllocStatuses give us this order.
+ if (a.jobVersion === b.jobVersion) {
+ return (
+ jobAllocStatuses[this.type].indexOf(b.clientStatus) -
+ jobAllocStatuses[this.type].indexOf(a.clientStatus)
+ );
+ } else {
+ return 0;
+ }
+ })
+ .reverse();
+
+ // Iterate over the sorted allocs
+ for (const alloc of sortedAllocs) {
+ if (availableSlotsToFill === 0) {
+ break;
+ }
+
+ const status = alloc.clientStatus;
+ // If the alloc has another clientStatus, add it to the corresponding list
+ // as long as we haven't reached the expectedRunningAllocCount limit for that clientStatus
+ if (
+ this.allocTypes.map(({ label }) => label).includes(status) &&
+ allocationsOfShowableType[status].healthy.nonCanary.length <
+ this.expectedRunningAllocCount
+ ) {
+ allocationsOfShowableType[status].healthy.nonCanary.push(alloc);
+ availableSlotsToFill--;
+ }
+ }
+ }
+
+ // // Handle unplaced allocs
+ // if (availableSlotsToFill > 0) {
+ // // TODO: JSDoc types for unhealty and health unknown aren't optional, but should be.
+ // allocationsOfShowableType['unplaced'] = {
+ // healthy: {
+ // nonCanary: Array(availableSlotsToFill)
+ // .fill()
+ // .map(() => {
+ // return { clientStatus: 'unplaced' };
+ // }),
+ // },
+ // };
+ // }
+
+ // Fill unplaced slots if availableSlotsToFill > 0
+ if (availableSlotsToFill > 0) {
+ allocationsOfShowableType['unplaced'] = {
+ healthy: { canary: [], nonCanary: [] },
+ unhealthy: { canary: [], nonCanary: [] },
+ health_unknown: { canary: [], nonCanary: [] },
+ };
+ allocationsOfShowableType['unplaced']['healthy']['nonCanary'] = Array(
+ availableSlotsToFill
+ )
+ .fill()
+ .map(() => {
+ return { clientStatus: 'unplaced' };
+ });
+ }
+
+ // console.log('allocBlocks for', this.name, 'is', allocationsOfShowableType);
+
+ return allocationsOfShowableType;
+ }
+
+ /**
+ * A single status to indicate how a job is doing, based on running/healthy allocations vs desired.
+ * Possible statuses are:
+ * - Deploying: A deployment is actively taking place
+ * - Complete: (Batch/Sysbatch only) All expected allocations are complete
+ * - Running: (Batch/Sysbatch only) All expected allocations are running
+ * - Healthy: All expected allocations are running and healthy
+ * - Recovering: Some allocations are pending
+ * - Degraded: A deployment is not taking place, and some allocations are failed, lost, or unplaced
+ * - Failed: All allocations are failed, lost, or unplaced
+ * - Removed: The job appeared in our initial query, but has since been garbage collected
+ * @returns {CurrentStatus}
+ */
+ /**
+ * A general assessment for how a job is going, in a non-deployment state
+ * @returns {CurrentStatus}
+ */
+ get aggregateAllocStatus() {
+ let totalAllocs = this.expectedRunningAllocCount;
+
+ // If deploying:
+ if (this.latestDeploymentSummary.isActive) {
+ return { label: 'Deploying', state: 'highlight' };
+ }
+
+ // If the job was requested initially, but a subsequent request for it was
+ // not found, we can remove links to it but maintain its presence in the list
+ // until the user specifies they want a refresh
+ if (this.assumeGC) {
+ return { label: 'Removed', state: 'neutral' };
+ }
+
+ if (this.type === 'batch' || this.type === 'sysbatch') {
+ // TODO: showing as failed when long-complete
+ // If all the allocs are complete, the job is Complete
+ const completeAllocs = this.allocBlocks.complete?.healthy?.nonCanary;
+ if (completeAllocs?.length === totalAllocs) {
+ return { label: 'Complete', state: 'success' };
+ }
+
+ // If any allocations are running the job is "Running"
+ const healthyAllocs = this.allocBlocks.running?.healthy?.nonCanary;
+ if (healthyAllocs?.length + completeAllocs?.length === totalAllocs) {
+ return { label: 'Running', state: 'success' };
+ }
+ }
+
+ // All the exepected allocs are running and healthy? Congratulations!
+ const healthyAllocs = this.allocBlocks.running?.healthy?.nonCanary;
+ if (totalAllocs && healthyAllocs?.length === totalAllocs) {
+ return { label: 'Healthy', state: 'success' };
+ }
+
+ // If any allocations are pending the job is "Recovering"
+ // Note: Batch/System jobs (which do not have deployments)
+ // go into "recovering" right away, since some of their statuses are
+ // "pending" as they come online. This feels a little wrong but it's kind
+ // of correct?
+ const pendingAllocs = this.allocBlocks.pending?.healthy?.nonCanary;
+ if (pendingAllocs?.length > 0) {
+ return { label: 'Recovering', state: 'highlight' };
+ }
+
+ // If any allocations are failed, lost, or unplaced in a steady state,
+ // the job is "Degraded"
+ const failedOrLostAllocs = [
+ ...this.allocBlocks.failed?.healthy?.nonCanary,
+ ...this.allocBlocks.lost?.healthy?.nonCanary,
+ ...this.allocBlocks.unplaced?.healthy?.nonCanary,
+ ];
+
+ if (failedOrLostAllocs.length >= totalAllocs) {
+ return { label: 'Failed', state: 'critical' };
+ } else {
+ return { label: 'Degraded', state: 'warning' };
+ }
+ }
@fragment('structured-attributes') meta;
get isPack() {
diff --git a/ui/app/routes/jobs/index.js b/ui/app/routes/jobs/index.js
index fd81cf89e785..750dbe93dbb3 100644
--- a/ui/app/routes/jobs/index.js
+++ b/ui/app/routes/jobs/index.js
@@ -3,46 +3,200 @@
* SPDX-License-Identifier: BUSL-1.1
*/
+// @ts-check
+
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import RSVP from 'rsvp';
import { collect } from '@ember/object/computed';
-import { watchAll, watchQuery } from 'nomad-ui/utils/properties/watch';
+import { watchAll } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
+import { action } from '@ember/object';
+import Ember from 'ember';
+
+const DEFAULT_THROTTLE = 2000;
export default class IndexRoute extends Route.extend(
WithWatchers,
WithForbiddenState
) {
@service store;
+ @service watchList;
+ @service notifications;
queryParams = {
qpNamespace: {
refreshModel: true,
},
+ cursorAt: {
+ refreshModel: true,
+ },
+ pageSize: {
+ refreshModel: true,
+ },
+ filter: {
+ refreshModel: true,
+ },
};
- model(params) {
- return RSVP.hash({
- jobs: this.store
- .query('job', { namespace: params.qpNamespace, meta: true })
- .catch(notifyForbidden(this)),
- namespaces: this.store.findAll('namespace'),
- nodePools: this.store.findAll('node-pool'),
+ hasBeenInitialized = false;
+
+ getCurrentParams() {
+ let queryParams = this.paramsFor(this.routeName); // Get current query params
+ if (queryParams.cursorAt) {
+ queryParams.next_token = queryParams.cursorAt;
+ }
+ queryParams.per_page = queryParams.pageSize;
+
+ /* eslint-disable ember/no-controller-access-in-routes */
+ let filter = this.controllerFor('jobs.index').filter;
+ if (filter) {
+ queryParams.filter = filter;
+ }
+ // namespace
+ queryParams.namespace = queryParams.qpNamespace;
+ delete queryParams.qpNamespace;
+ delete queryParams.pageSize;
+ delete queryParams.cursorAt;
+
+ return { ...queryParams };
+ }
+
+ async model(/*params*/) {
+ let currentParams = this.getCurrentParams();
+ this.watchList.jobsIndexIDsController.abort();
+ this.watchList.jobsIndexIDsController = new AbortController();
+ try {
+ let jobs = await this.store.query('job', currentParams, {
+ adapterOptions: {
+ abortController: this.watchList.jobsIndexIDsController,
+ },
+ });
+ return RSVP.hash({
+ jobs,
+ namespaces: this.store.findAll('namespace'),
+ nodePools: this.store.findAll('node-pool'),
+ });
+ } catch (error) {
+ try {
+ notifyForbidden(this)(error);
+ } catch (secondaryError) {
+ return this.handleErrors(error);
+ }
+ }
+ return {};
+ }
+
+ /**
+ * @typedef {Object} HTTPError
+ * @property {string} stack
+ * @property {string} message
+ * @property {string} name
+ * @property {HTTPErrorDetail[]} errors
+ */
+
+ /**
+ * @typedef {Object} HTTPErrorDetail
+ * @property {string} status - HTTP status code
+ * @property {string} title
+ * @property {string} detail
+ */
+
+ /**
+ * Handles HTTP errors by returning an appropriate message based on the HTTP status code and details in the error object.
+ *
+ * @param {HTTPError} error
+ * @returns {Object}
+ */
+ handleErrors(error) {
+ console.log('handling error', error);
+ error.errors.forEach((err) => {
+ this.notifications.add({
+ title: err.title,
+ message: err.detail,
+ color: 'critical',
+ timeout: 8000,
+ });
});
+
+ // if it's an innocuous-enough seeming "You mistyped something while searching" error,
+ // handle it with a notification and don't throw. Otherwise, throw.
+ if (
+ error.errors[0].detail.includes("couldn't find key") ||
+ error.errors[0].detail.includes('failed to read filter expression')
+ ) {
+ return error;
+ } else {
+ throw error;
+ }
}
- startWatchers(controller) {
- controller.set('namespacesWatch', this.watchNamespaces.perform());
+ setupController(controller, model) {
+ super.setupController(controller, model);
+
+ if (!model.jobs) {
+ return;
+ }
+
+ controller.set('nextToken', model.jobs.meta.nextToken);
+ controller.set('jobQueryIndex', model.jobs.meta.index);
+ controller.set('jobAllocsQueryIndex', model.jobs.meta.allocsIndex); // Assuming allocsIndex is your meta key for job allocations.
controller.set(
- 'modelWatch',
- this.watchJobs.perform({ namespace: controller.qpNamespace, meta: true })
+ 'jobIDs',
+ model.jobs.map((job) => {
+ return {
+ id: job.plainId,
+ namespace: job.belongsTo('namespace').id(),
+ };
+ })
);
+
+ // Now that we've set the jobIDs, immediately start watching them
+ controller.watchJobs.perform(
+ controller.jobIDs,
+ Ember.testing ? 0 : DEFAULT_THROTTLE,
+ 'update'
+ );
+ // And also watch for any changes to the jobIDs list
+ controller.watchJobIDs.perform(
+ this.getCurrentParams(),
+ Ember.testing ? 0 : DEFAULT_THROTTLE
+ );
+
+ controller.parseFilter();
+
+ this.hasBeenInitialized = true;
+ }
+
+ startWatchers(controller) {
+ controller.set('namespacesWatch', this.watchNamespaces.perform());
+ }
+
+ @action
+ willTransition(transition) {
+ if (!transition.intent.name?.startsWith(this.routeName)) {
+ this.watchList.jobsIndexDetailsController.abort();
+ this.watchList.jobsIndexIDsController.abort();
+ // eslint-disable-next-line
+ this.controller.watchJobs.cancelAll();
+ // eslint-disable-next-line
+ this.controller.watchJobIDs.cancelAll();
+ }
+ this.cancelAllWatchers();
+ return true;
+ }
+
+ // Determines if we should be put into a loading state (jobs/loading.hbs)
+ // This is a useful page for when you're first initializing your jobs list,
+ // but overkill when we paginate / change our queryParams. We should handle that
+ // with in-compnent loading/skeleton states instead.
+ @action
+ loading() {
+ return !this.hasBeenInitialized; // allows the loading template to be shown
}
- @watchQuery('job') watchJobs;
@watchAll('namespace') watchNamespaces;
- @collect('watchJobs', 'watchNamespaces') watchers;
+ @collect('watchNamespaces') watchers;
}
diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js
index e6b2eeb89214..59656ef3a9db 100644
--- a/ui/app/routes/jobs/job.js
+++ b/ui/app/routes/jobs/job.js
@@ -44,7 +44,7 @@ export default class JobRoute extends Route.extend(WithWatchers) {
const relatedModelsQueries = [
job.get('allocations'),
job.get('evaluations'),
- this.store.query('job', { namespace, meta: true }),
+ // this.store.query('job', { namespace, meta: true }), // TODO: I think I am probably nuking the ability to get meta:pack info here. See https://github.com/hashicorp/nomad/pull/14833
this.store.findAll('namespace'),
];
diff --git a/ui/app/routes/optimize.js b/ui/app/routes/optimize.js
index 6918f78bbe6c..ca1a921ce6d3 100644
--- a/ui/app/routes/optimize.js
+++ b/ui/app/routes/optimize.js
@@ -32,6 +32,15 @@ export default class OptimizeRoute extends Route {
.map((j) => j.reload()),
]);
+ // reload the /allocations for each job,
+ // as the jobs-index-provided ones are less detailed than what
+ // the optimize/recommendation components require
+ await RSVP.all(
+ jobs
+ .filter((job) => job)
+ .map((j) => this.store.query('allocation', { job_id: j.id }))
+ );
+
return {
summaries: summaries.sortBy('submitTime').reverse(),
namespaces,
diff --git a/ui/app/serializers/allocation.js b/ui/app/serializers/allocation.js
index 276be41114de..3bc595e792af 100644
--- a/ui/app/serializers/allocation.js
+++ b/ui/app/serializers/allocation.js
@@ -49,6 +49,8 @@ export default class AllocationSerializer extends ApplicationSerializer {
.sort()
.map((key) => {
const state = states[key] || {};
+ // make sure events, if null, is an empty array
+ state.Events = state.Events || [];
const summary = { Name: key };
Object.keys(state).forEach(
(stateKey) => (summary[stateKey] = state[stateKey])
diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js
index b40e84ce9b97..6662d6e78115 100644
--- a/ui/app/serializers/job.js
+++ b/ui/app/serializers/job.js
@@ -7,6 +7,7 @@ import { assign } from '@ember/polyfills';
import ApplicationSerializer from './application';
import queryString from 'query-string';
import classic from 'ember-classic-decorator';
+import { camelize } from '@ember/string';
@classic
export default class JobSerializer extends ApplicationSerializer {
@@ -60,6 +61,93 @@ export default class JobSerializer extends ApplicationSerializer {
return super.normalize(typeHash, hash);
}
+ normalizeQueryResponse(store, primaryModelClass, payload, id, requestType) {
+ // What jobs did we ask for?
+ if (payload._requestBody?.jobs) {
+ let requestedJobIDs = payload._requestBody.jobs;
+ // If they dont match the jobIDs we got back, we need to create an empty one
+ // for the ones we didnt get back.
+ payload.forEach((job) => {
+ job.AssumeGC = false;
+ });
+ let missingJobIDs = requestedJobIDs.filter(
+ (j) =>
+ !payload.find((p) => p.ID === j.id && p.Namespace === j.namespace)
+ );
+ missingJobIDs.forEach((job) => {
+ payload.push({
+ ID: job.id,
+ Namespace: job.namespace,
+ Allocs: [],
+ AssumeGC: true,
+ });
+
+ job.relationships = {
+ allocations: {
+ data: [],
+ },
+ };
+ });
+
+ // Note: we want our returned jobs to come back in the order we requested them,
+ // including jobs that were missing from the initial request.
+ payload.sort((a, b) => {
+ return (
+ requestedJobIDs.findIndex(
+ (j) => j.id === a.ID && j.namespace === a.Namespace
+ ) -
+ requestedJobIDs.findIndex(
+ (j) => j.id === b.ID && j.namespace === b.Namespace
+ )
+ );
+ });
+
+ delete payload._requestBody;
+ }
+
+ const jobs = payload;
+ // Signal that it's a query response at individual normalization level for allocation placement
+ // Sort by ModifyIndex, reverse
+ jobs.sort((a, b) => b.ModifyIndex - a.ModifyIndex);
+ jobs.forEach((job) => {
+ if (job.Allocs) {
+ job.relationships = {
+ allocations: {
+ data: job.Allocs.map((alloc) => ({
+ id: alloc.id,
+ type: 'allocation',
+ })),
+ },
+ };
+ }
+ if (job.LatestDeployment) {
+ // camelize property names and save it as a non-conflicting name (latestDeployment is already used as a computed property on the job model)
+ job.LatestDeploymentSummary = Object.keys(job.LatestDeployment).reduce(
+ (acc, key) => {
+ if (key === 'ID') {
+ acc.id = job.LatestDeployment[key];
+ } else {
+ acc[camelize(key)] = job.LatestDeployment[key];
+ }
+ return acc;
+ },
+ {}
+ );
+ delete job.LatestDeployment;
+ } else {
+ job.LatestDeploymentSummary = {};
+ }
+ job._aggregate = true;
+ });
+ return super.normalizeQueryResponse(
+ store,
+ primaryModelClass,
+ jobs,
+ id,
+ requestType
+ );
+ }
+
extractRelationships(modelClass, hash) {
const namespace =
!hash.NamespaceID || hash.NamespaceID === 'default'
@@ -80,8 +168,47 @@ export default class JobSerializer extends ApplicationSerializer {
? JSON.parse(hash.ParentID)[0]
: hash.PlainId;
+ if (hash._aggregate && hash.Allocs) {
+ // Manually push allocations to store
+ // These allocations have enough information to be useful on a jobs index page,
+ // but less than the /allocations endpoint for an individual job might give us.
+ // As such, pages like /optimize require a specific call to the endpoint
+ // of any jobs' allocations to get more detailed information.
+ hash.Allocs.forEach((alloc) => {
+ this.store.push({
+ data: {
+ id: alloc.ID,
+ type: 'allocation',
+ attributes: {
+ clientStatus: alloc.ClientStatus,
+ deploymentStatus: {
+ Healthy: alloc.DeploymentStatus.Healthy,
+ Canary: alloc.DeploymentStatus.Canary,
+ },
+ jobVersion: alloc.JobVersion,
+ nodeID: alloc.NodeID,
+ },
+ relationships: {
+ followUpEvaluation: alloc.FollowupEvalID && {
+ data: {
+ id: alloc.FollowupEvalID,
+ type: 'evaluation',
+ },
+ },
+ },
+ },
+ });
+ });
+
+ delete hash._aggregate;
+ }
+
return assign(super.extractRelationships(...arguments), {
allocations: {
+ data: hash.Allocs?.map((alloc) => ({
+ id: alloc.ID,
+ type: 'allocation',
+ })),
links: {
related: buildURL(`${jobURL}/allocations`, { namespace }),
},
diff --git a/ui/app/services/system.js b/ui/app/services/system.js
index f5bede53d3db..d1408a3c4f22 100644
--- a/ui/app/services/system.js
+++ b/ui/app/services/system.js
@@ -136,6 +136,10 @@ export default class SystemService extends Service {
);
}
+ get shouldShowNodepools() {
+ return true; // TODO: make this dependent on there being at least one non-default node pool
+ }
+
@task(function* () {
const emptyLicense = { License: { Features: [] } };
diff --git a/ui/app/services/watch-list.js b/ui/app/services/watch-list.js
index 80a44d138c14..873d9ae2fc38 100644
--- a/ui/app/services/watch-list.js
+++ b/ui/app/services/watch-list.js
@@ -30,4 +30,7 @@ export default class WatchListService extends Service {
setIndexFor(url, value) {
list[url] = +value;
}
+
+ jobsIndexIDsController = new AbortController();
+ jobsIndexDetailsController = new AbortController();
}
diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss
index 3321362e3e4b..a1cb9dcdf976 100644
--- a/ui/app/styles/components.scss
+++ b/ui/app/styles/components.scss
@@ -60,3 +60,4 @@
@import './components/job-status-panel';
@import './components/access-control';
@import './components/actions';
+@import './components/jobs-list';
diff --git a/ui/app/styles/components/job-status-panel.scss b/ui/app/styles/components/job-status-panel.scss
index d488abf65951..e689ce16a665 100644
--- a/ui/app/styles/components/job-status-panel.scss
+++ b/ui/app/styles/components/job-status-panel.scss
@@ -405,6 +405,25 @@
}
}
+ .allocation-status-row.compact {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 1rem;
+ max-width: 400px;
+ min-width: 200px;
+ .alloc-status-summaries {
+ height: 6px;
+ gap: 6px;
+ .represented-allocation {
+ height: 6px;
+ .rest-count {
+ display: none;
+ }
+ }
+ }
+ }
+
.legend-item .represented-allocation .flight-icon {
animation: none;
}
diff --git a/ui/app/styles/components/jobs-list.scss b/ui/app/styles/components/jobs-list.scss
new file mode 100644
index 000000000000..fd969074df40
--- /dev/null
+++ b/ui/app/styles/components/jobs-list.scss
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+// Styling for compnents around the redesigned Jobs Index page.
+// Over time, we can phase most of these custom styles out as Helios components
+// adapt to more use-cases (like custom footer, etc).
+
+#jobs-list-header {
+ z-index: $z-base;
+}
+
+#jobs-list-actions {
+ margin-bottom: 1rem;
+ // If the screen is made very small, don't try to multi-line the text,
+ // instead wrap the flex and let dropdowns/buttons go on a new line.
+ .hds-segmented-group {
+ flex-wrap: wrap;
+ gap: 0.5rem 0;
+ }
+}
+
+#jobs-list-pagination {
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ grid-template-areas: 'info nav-buttons page-size';
+ align-items: center;
+ justify-items: start;
+ padding: 1rem 0;
+ gap: 1rem;
+
+ .nav-buttons {
+ grid-area: nav-buttons;
+ display: flex;
+ justify-content: center;
+ gap: 1rem;
+ }
+
+ .page-size {
+ grid-area: page-size;
+ justify-self: end;
+ }
+}
+
+// TODO: make this a little cleaner.
+.status-cell {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
diff --git a/ui/app/templates/components/job-row.hbs b/ui/app/templates/components/job-row.hbs
index 3f062378b5d1..e4a217a53fc5 100644
--- a/ui/app/templates/components/job-row.hbs
+++ b/ui/app/templates/components/job-row.hbs
@@ -3,71 +3,111 @@
SPDX-License-Identifier: BUSL-1.1
~}}
-
-
- {{this.job.name}}
- {{#if this.job.meta.structured.pack}}
-
- {{x-icon "box" class= "test"}}
- Pack
-
+ <@tableBody.Td data-test-job-name>
+ {{#if @job.assumeGC}}
+ {{@job.name}}
+ {{else}}
+
+ {{@job.name}}
+ {{!-- TODO: going to lose .meta with statuses endpoint! --}}
+ {{#if @job.meta.structured.pack}}
+
+ {{x-icon "box" class= "test"}}
+ Pack
+
+ {{/if}}
+
{{/if}}
+ @tableBody.Td>
-
- |
-{{#if (not (eq @context "child"))}}
{{#if this.system.shouldShowNamespaces}}
-
- {{this.job.namespace.name}}
- |
+ <@tableBody.Td data-test-job-namespace>{{@job.namespace.id}}@tableBody.Td>
{{/if}}
-{{/if}}
-{{#if (eq @context "child")}}
-
- {{format-month-ts this.job.submitTime}}
- |
-{{/if}}
-
-
- {{this.job.status}}
-
- |
-{{#if (not (eq @context "child"))}}
-
- {{this.job.displayType.type}}
- |
-
- {{#if this.job.nodePool}}{{this.job.nodePool}}{{else}}-{{/if}}
- |
-
- {{this.job.priority}}
- |
-{{/if}}
-
-
- {{#if this.job.hasChildren}}
- {{#if (gt this.job.totalChildren 0)}}
-
- {{else}}
-
- No Children
-
- {{/if}}
- {{else}}
-
- {{/if}}
-
- |
\ No newline at end of file
+ <@tableBody.Td data-test-job-status>
+ {{#unless @job.childStatuses}}
+
+
+ {{#if this.latestDeploymentFailed}}
+
+ {{/if}}
+
+ {{/unless}}
+ @tableBody.Td>
+ <@tableBody.Td data-test-job-type={{@job.type}}>
+ {{@job.type}}
+ @tableBody.Td>
+
+ {{#if this.system.shouldShowNodepools}}
+ <@tableBody.Td data-test-job-node-pool>{{@job.nodePool}}@tableBody.Td>
+ {{/if}}
+
+ <@tableBody.Td>
+
+ {{#unless @job.assumeGC}}
+ {{#if @job.childStatuses}}
+ {{@job.childStatuses.length}} child jobs;
+ {{#each-in @job.childStatusBreakdown as |status count|}}
+ {{count}} {{status}}
+ {{/each-in}}
+ {{else}}
+
+
+ {{#if this.requiresPromotionTask.isRunning}}
+ Loading...
+ {{else if this.requiresPromotionTask.lastSuccessful.value}}
+ {{#if (eq this.requiresPromotionTask.lastSuccessful.value "canary-promote")}}
+
+ {{else if (eq this.requiresPromotionTask.lastSuccessful.value "canary-failure")}}
+
+ {{/if}}
+ {{/if}}
+
+ {{/if}}
+ {{/unless}}
+
+ @tableBody.Td>
+@tableBody.Tr>
diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs
index 0c577a7e7a76..629e09b7074f 100644
--- a/ui/app/templates/jobs/index.hbs
+++ b/ui/app/templates/jobs/index.hbs
@@ -5,218 +5,223 @@
{{page-title "Jobs"}}
-
+
{{/if}}
-
- {{#if this.isForbidden}}
-
- {{else if this.sortedJobs}}
-
-
-
-
- Name
-
- {{#if this.system.shouldShowNamespaces}}
-
- Namespace
-
- {{/if}}
-
- Status
-
-
- Type
-
-
- Node Pool
-
-
- Priority
-
-
- Summary
- |
-
-
-
-
-
-
-
- {{else}}
-
- {{#if (eq this.visibleJobs.length 0)}}
-
- No Jobs
-
-
- The cluster is currently empty.
-
- {{else if (eq this.filteredJobs.length 0)}}
-
- No Matches
-
-
- No jobs match your current filter selection.
-
- {{else if this.searchTerm}}
-
- No Matches
-
-
- No jobs match the term
-
- {{this.searchTerm}}
-
-
- {{/if}}
-
- {{/if}}
-
\ No newline at end of file
+
diff --git a/ui/app/templates/settings/tokens.hbs b/ui/app/templates/settings/tokens.hbs
index 22473668b559..513e3e64a533 100644
--- a/ui/app/templates/settings/tokens.hbs
+++ b/ui/app/templates/settings/tokens.hbs
@@ -35,6 +35,36 @@
+
+ User Settings
+ These settings will be saved to your browser settings via Local Storage.
+
+
+
+ UI
+
+ Word Wrap
+ Wrap lines of text in logs and exec terminals in the UI
+
+
+ Live Updates to Jobs Index
+ When enabled, new or removed jobs will pop into and out of view on your jobs page. When disabled, you will be notified that changes are pending.
+
+
+
+
+
+
{{#if (eq this.signInStatus "failure")}}
{
+ // Dropdowns user parenthesis wrapping; remove them for mock/test purposes
+ // We want to test multiple conditions within parens, like "(Type == system or Type == sysbatch)"
+ // So if we see parenthesis, we should re-split on "or" and treat them as separate conditions.
+ if (condition.startsWith('(') && condition.endsWith(')')) {
+ condition = condition.slice(1, -1);
+ }
+ if (condition.includes(' or ')) {
+ // multiple or condition
+ return {
+ field: condition.split(' ')[0],
+ operator: '==',
+ parts: condition.split(' or ').map((part) => {
+ return part.split(' ')[2];
+ }),
+ };
+ } else {
+ const parts = condition.split(' ');
+
+ return {
+ field: parts[0],
+ operator: parts[1],
+ value: parts.slice(2).join(' ').replace(/['"]+/g, ''),
+ };
+ }
+ });
+
+ filteredJson = filteredJson.filter((job) => {
+ return filterConditions.every((condition) => {
+ if (condition.parts) {
+ // Making a shortcut assumption that any condition.parts situations
+ // will be == as operator for testing sake.
+ return condition.parts.some((part) => {
+ return job[condition.field] === part;
+ });
+ }
+ if (condition.operator === 'contains') {
+ return (
+ job[condition.field] &&
+ job[condition.field].includes(condition.value)
+ );
+ } else if (condition.operator === '==') {
+ return job[condition.field] === condition.value;
+ } else if (condition.operator === '!=') {
+ return job[condition.field] !== condition.value;
+ }
+ return true;
+ });
+ });
+ }
+
+ let sortedJson = filteredJson
+ .sort((a, b) =>
+ reverse
+ ? a.ModifyIndex - b.ModifyIndex
+ : b.ModifyIndex - a.ModifyIndex
+ )
+ .filter((job) => {
+ if (namespace === '*') return true;
+ return namespace === 'default'
+ ? !job.NamespaceID || job.NamespaceID === 'default'
+ : job.NamespaceID === namespace;
+ })
+ .map((job) => filterKeys(job, 'TaskGroups', 'NamespaceID'));
+ if (nextToken) {
+ sortedJson = sortedJson.filter((job) =>
+ reverse
+ ? job.ModifyIndex >= nextToken
+ : job.ModifyIndex <= nextToken
+ );
+ }
+ return sortedJson;
+ },
+ { pagination: true, tokenProperty: 'ModifyIndex' }
+ )
+ );
+
+ this.post(
+ '/jobs/statuses',
+ withBlockingSupport(function ({ jobs }, req) {
+ const body = JSON.parse(req.requestBody);
+ const requestedJobs = body.jobs || [];
+ const allJobs = this.serialize(jobs.all());
+
+ let returnedJobs = allJobs
+ .filter((job) => {
+ return requestedJobs.some((requestedJob) => {
+ return (
+ job.ID === requestedJob.id &&
+ (requestedJob.namespace === 'default' ||
+ job.NamespaceID === requestedJob.namespace)
+ );
+ });
+ })
+ .map((j) => {
+ let jobDeployments = server.db.deployments.where({
+ jobId: j.ID,
+ namespace: j.Namespace,
+ });
+ let job = {};
+ job.ID = j.ID;
+ job.Name = j.Name;
+ job.ModifyIndex = j.ModifyIndex;
+ job.LatestDeployment = {
+ ID: jobDeployments[0]?.id,
+ IsActive: jobDeployments[0]?.status === 'running',
+ // IsActive: true,
+ JobVersion: jobDeployments[0]?.versionNumber,
+ Status: jobDeployments[0]?.status,
+ StatusDescription: jobDeployments[0]?.statusDescription,
+ AllAutoPromote: false,
+ RequiresPromotion: true, // TODO: lever
+ };
+ job.Allocs = server.db.allocations
+ .where({ jobId: j.ID, namespace: j.Namespace })
+ .map((alloc) => {
+ return {
+ ClientStatus: alloc.clientStatus,
+ DeploymentStatus: {
+ Canary: false,
+ Healthy: true,
+ },
+ Group: alloc.taskGroup,
+ JobVersion: alloc.jobVersion,
+ NodeID: alloc.nodeId,
+ ID: alloc.id,
+ };
+ });
+ job.ChildStatuses = null;
+ job.Datacenters = j.Datacenters;
+ job.DeploymentID = j.DeploymentID;
+ job.GroupCountSum = j.TaskGroups.mapBy('Count').reduce(
+ (a, b) => a + b,
+ 0
+ );
+ job.Namespace = j.NamespaceID;
+ job.NodePool = j.NodePool;
+ job.Type = j.Type;
+ job.Priority = j.Priority;
+ job.Version = j.Version;
+ return job;
+ });
+ // sort by modifyIndex, descending
+ returnedJobs
+ .sort((a, b) => b.ID.localeCompare(a.ID))
+ .sort((a, b) => b.ModifyIndex - a.ModifyIndex);
+
+ return returnedJobs;
+ })
+ );
+
this.post('/jobs', function (schema, req) {
const body = JSON.parse(req.requestBody);
diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js
index fc70d71542cc..d4828d5ae6df 100644
--- a/ui/mirage/scenarios/default.js
+++ b/ui/mirage/scenarios/default.js
@@ -28,6 +28,7 @@ export const allScenarios = {
policiesTestCluster,
rolesTestCluster,
namespacesTestCluster,
+ jobsIndexTestCluster,
...topoScenarios,
...sysbatchScenarios,
};
@@ -57,6 +58,23 @@ export default function (server) {
// Scenarios
+function jobsIndexTestCluster(server) {
+ faker.seed(1);
+ server.createList('agent', 1, 'withConsulLink', 'withVaultLink');
+ server.createList('node', 1);
+ server.create('node-pool');
+
+ const jobsToCreate = 55;
+ for (let i = 0; i < jobsToCreate; i++) {
+ let groupCount = Math.floor(Math.random() * 2) + 1;
+ server.create('job', {
+ resourceSpec: Array(groupCount).fill('M: 256, C: 500'),
+ groupAllocCount: Math.floor(Math.random() * 3) + 1,
+ modifyIndex: i + 1,
+ });
+ }
+}
+
function smallCluster(server) {
faker.seed(1);
server.create('feature', { name: 'Dynamic Application Sizing' });
@@ -71,7 +89,7 @@ function smallCluster(server) {
},
'withMeta'
);
- server.createList('job', 1, { createRecommendations: true });
+ server.createList('job', 10, { createRecommendations: true });
server.create('job', {
withGroupServices: true,
withTaskServices: true,
diff --git a/ui/tests/acceptance/application-errors-test.js b/ui/tests/acceptance/application-errors-test.js
index 52d9c8a24636..e74f7746820d 100644
--- a/ui/tests/acceptance/application-errors-test.js
+++ b/ui/tests/acceptance/application-errors-test.js
@@ -70,7 +70,11 @@ module('Acceptance | application errors ', function (hooks) {
test('the no leader error state gets its own error message', async function (assert) {
assert.expect(2);
- server.pretender.get('/v1/jobs', () => [500, {}, 'No cluster leader']);
+ server.pretender.get('/v1/jobs/statuses', () => [
+ 500,
+ {},
+ 'No cluster leader',
+ ]);
await JobsList.visit();
diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js
index 820a2ad79550..309584b8392c 100644
--- a/ui/tests/acceptance/jobs-list-test.js
+++ b/ui/tests/acceptance/jobs-list-test.js
@@ -4,7 +4,12 @@
*/
/* eslint-disable qunit/require-expect */
-import { currentURL, click } from '@ember/test-helpers';
+import {
+ currentURL,
+ settled,
+ click,
+ triggerKeyEvent,
+} from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
@@ -53,7 +58,10 @@ module('Acceptance | jobs list', function (hooks) {
await percySnapshot(assert);
- const sortedJobs = server.db.jobs.sortBy('modifyIndex').reverse();
+ const sortedJobs = server.db.jobs
+ .sortBy('id')
+ .sortBy('modifyIndex')
+ .reverse();
assert.equal(JobsList.jobs.length, JobsList.pageSize);
JobsList.jobs.forEach((job, index) => {
assert.equal(job.name, sortedJobs[index].name, 'Jobs are ordered');
@@ -66,15 +74,24 @@ module('Acceptance | jobs list', function (hooks) {
await JobsList.visit();
+ const store = this.owner.lookup('service:store');
+ const jobInStore = await store.peekRecord(
+ 'job',
+ `["${job.id}","${job.namespace}"]`
+ );
+
const jobRow = JobsList.jobs.objectAt(0);
assert.equal(jobRow.name, job.name, 'Name');
assert.notOk(jobRow.hasNamespace);
assert.equal(jobRow.nodePool, job.nodePool, 'Node Pool');
assert.equal(jobRow.link, `/ui/jobs/${job.id}@default`, 'Detail Link');
- assert.equal(jobRow.status, job.status, 'Status');
+ assert.equal(
+ jobRow.status,
+ jobInStore.aggregateAllocStatus.label,
+ 'Status'
+ );
assert.equal(jobRow.type, typeForJob(job), 'Type');
- assert.equal(jobRow.priority, job.priority, 'Priority');
});
test('each job row should link to the corresponding job', async function (assert) {
@@ -143,6 +160,7 @@ module('Acceptance | jobs list', function (hooks) {
await JobsList.visit();
await JobsList.search.fillIn('dog');
+
assert.ok(JobsList.isEmpty, 'The empty message is shown');
assert.equal(
JobsList.emptyState.headline,
@@ -157,58 +175,20 @@ module('Acceptance | jobs list', function (hooks) {
});
await JobsList.visit();
- await JobsList.nextPage();
+ await click('[data-test-pager="next"]');
- assert.equal(
- currentURL(),
- '/jobs?page=2',
- 'Page query param captures page=2'
+ assert.ok(
+ currentURL().includes('cursorAt'),
+ 'Page query param contains cursorAt'
);
await JobsList.search.fillIn('foobar');
- assert.equal(currentURL(), '/jobs?search=foobar', 'No page query param');
- });
-
- test('Search order overrides Sort order', async function (assert) {
- server.create('job', { name: 'car', modifyIndex: 1, priority: 200 });
- server.create('job', { name: 'cat', modifyIndex: 2, priority: 150 });
- server.create('job', { name: 'dog', modifyIndex: 3, priority: 100 });
- server.create('job', { name: 'dot', modifyIndex: 4, priority: 50 });
-
- await JobsList.visit();
-
- // Expect list to be in reverse modifyIndex order by default
- assert.equal(JobsList.jobs.objectAt(0).name, 'dot');
- assert.equal(JobsList.jobs.objectAt(1).name, 'dog');
- assert.equal(JobsList.jobs.objectAt(2).name, 'cat');
- assert.equal(JobsList.jobs.objectAt(3).name, 'car');
-
- // When sorting by name, expect list to be in alphabetical order
- await click('[data-test-sort-by="name"]'); // sorts desc
- await click('[data-test-sort-by="name"]'); // sorts asc
-
- assert.equal(JobsList.jobs.objectAt(0).name, 'car');
- assert.equal(JobsList.jobs.objectAt(1).name, 'cat');
- assert.equal(JobsList.jobs.objectAt(2).name, 'dog');
- assert.equal(JobsList.jobs.objectAt(3).name, 'dot');
-
- // When searching, the "name" sort is locked in. Fuzzy results for cat return both car and cat, but cat first.
- await JobsList.search.fillIn('cat');
- assert.equal(JobsList.jobs.length, 2);
- assert.equal(JobsList.jobs.objectAt(0).name, 'cat'); // higher fuzzy
- assert.equal(JobsList.jobs.objectAt(1).name, 'car');
-
- // Clicking priority sorter will maintain the search filter, but change the order
- await click('[data-test-sort-by="priority"]'); // sorts desc
- assert.equal(JobsList.jobs.objectAt(0).name, 'car'); // higher priority first
- assert.equal(JobsList.jobs.objectAt(1).name, 'cat');
-
- // Modifying search again will prioritize search "fuzzy" order
- await JobsList.search.fillIn(''); // trigger search reset
- await JobsList.search.fillIn('cat');
- assert.equal(JobsList.jobs.objectAt(0).name, 'cat'); // higher fuzzy
- assert.equal(JobsList.jobs.objectAt(1).name, 'car');
+ assert.equal(
+ currentURL(),
+ '/jobs?filter=Name%20contains%20foobar',
+ 'No page query param'
+ );
});
test('when a cluster has namespaces, each job row includes the job namespace', async function (assert) {
@@ -259,10 +239,11 @@ module('Acceptance | jobs list', function (hooks) {
});
test('when accessing jobs is forbidden, show a message with a link to the tokens page', async function (assert) {
- server.pretender.get('/v1/jobs', () => [403, {}, null]);
+ server.pretender.get('/v1/jobs/statuses', () => [403, {}, null]);
await JobsList.visit();
assert.equal(JobsList.error.title, 'Not Authorized');
+ await percySnapshot(assert);
await JobsList.error.seekHelp();
assert.equal(currentURL(), '/settings/tokens');
@@ -285,14 +266,17 @@ module('Acceptance | jobs list', function (hooks) {
);
assert.ok(JobsList.facets.type.isPresent, 'Type facet found');
assert.ok(JobsList.facets.status.isPresent, 'Status facet found');
- assert.ok(JobsList.facets.datacenter.isPresent, 'Datacenter facet found');
- assert.ok(JobsList.facets.prefix.isPresent, 'Prefix facet found');
+ assert.ok(JobsList.facets.nodePool.isPresent, 'Node Pools facet found');
+ assert.notOk(
+ JobsList.facets.namespace.isPresent,
+ 'Namespace facet not found by default'
+ );
});
testSingleSelectFacet('Namespace', {
facet: JobsList.facets.namespace,
paramName: 'namespace',
- expectedOptions: ['All (*)', 'default', 'namespace-2'],
+ expectedOptions: ['All', 'default', 'namespace-2'],
optionToSelect: 'namespace-2',
async beforeEach() {
server.create('namespace', { id: 'default' });
@@ -309,15 +293,7 @@ module('Acceptance | jobs list', function (hooks) {
testFacet('Type', {
facet: JobsList.facets.type,
paramName: 'type',
- expectedOptions: [
- 'Batch',
- 'Pack',
- 'Parameterized',
- 'Periodic',
- 'Service',
- 'System',
- 'System Batch',
- ],
+ expectedOptions: ['batch', 'service', 'system', 'sysbatch'],
async beforeEach() {
server.createList('job', 2, { createAllocations: false, type: 'batch' });
server.createList('job', 2, {
@@ -340,8 +316,6 @@ module('Acceptance | jobs list', function (hooks) {
},
filter(job, selection) {
let displayType = job.type;
- if (job.parameterized) displayType = 'parameterized';
- if (job.periodic) displayType = 'periodic';
return selection.includes(displayType);
},
});
@@ -349,7 +323,7 @@ module('Acceptance | jobs list', function (hooks) {
testFacet('Status', {
facet: JobsList.facets.status,
paramName: 'status',
- expectedOptions: ['Pending', 'Running', 'Dead'],
+ expectedOptions: ['pending', 'running', 'dead'],
async beforeEach() {
server.createList('job', 2, {
status: 'pending',
@@ -371,75 +345,6 @@ module('Acceptance | jobs list', function (hooks) {
filter: (job, selection) => selection.includes(job.status),
});
- testFacet('Datacenter', {
- facet: JobsList.facets.datacenter,
- paramName: 'dc',
- expectedOptions(jobs) {
- const allDatacenters = new Set(
- jobs.mapBy('datacenters').reduce((acc, val) => acc.concat(val), [])
- );
- return Array.from(allDatacenters).sort();
- },
- async beforeEach() {
- server.create('job', {
- datacenters: ['pdx', 'lax'],
- createAllocations: false,
- childrenCount: 0,
- });
- server.create('job', {
- datacenters: ['pdx', 'ord'],
- createAllocations: false,
- childrenCount: 0,
- });
- server.create('job', {
- datacenters: ['lax', 'jfk'],
- createAllocations: false,
- childrenCount: 0,
- });
- server.create('job', {
- datacenters: ['jfk', 'dfw'],
- createAllocations: false,
- childrenCount: 0,
- });
- server.create('job', {
- datacenters: ['pdx'],
- createAllocations: false,
- childrenCount: 0,
- });
- await JobsList.visit();
- },
- filter: (job, selection) =>
- job.datacenters.find((dc) => selection.includes(dc)),
- });
-
- testFacet('Prefix', {
- facet: JobsList.facets.prefix,
- paramName: 'prefix',
- expectedOptions: ['hashi (3)', 'nmd (2)', 'pre (2)'],
- async beforeEach() {
- [
- 'pre-one',
- 'hashi_one',
- 'nmd.one',
- 'one-alone',
- 'pre_two',
- 'hashi.two',
- 'hashi-three',
- 'nmd_two',
- 'noprefix',
- ].forEach((name) => {
- server.create('job', {
- name,
- createAllocations: false,
- childrenCount: 0,
- });
- });
- await JobsList.visit();
- },
- filter: (job, selection) =>
- selection.find((prefix) => job.name.startsWith(prefix)),
- });
-
test('when the facet selections result in no matches, the empty state states why', async function (assert) {
server.createList('job', 2, {
status: 'pending',
@@ -463,7 +368,7 @@ module('Acceptance | jobs list', function (hooks) {
server.create('job', { type: 'batch', createAllocations: false });
server.create('job', { type: 'service', createAllocations: false });
- await JobsList.visit({ type: JSON.stringify(['batch']) });
+ await JobsList.visit({ filter: 'Type == batch' });
assert.equal(
JobsList.jobs.length,
@@ -552,163 +457,1264 @@ module('Acceptance | jobs list', function (hooks) {
},
});
- async function facetOptions(assert, beforeEach, facet, expectedOptions) {
- await beforeEach();
- await facet.toggle();
+ test('the run job button works when filters are set', async function (assert) {
+ server.create('job', {
+ name: 'un',
+ createAllocations: false,
+ childrenCount: 0,
+ type: 'batch',
+ });
- let expectation;
- if (typeof expectedOptions === 'function') {
- expectation = expectedOptions(server.db.jobs);
- } else {
- expectation = expectedOptions;
- }
-
- assert.deepEqual(
- facet.options.map((option) => option.label.trim()),
- expectation,
- 'Options for facet are as expected'
- );
- }
+ server.create('job', {
+ name: 'deux',
+ createAllocations: false,
+ childrenCount: 0,
+ type: 'system',
+ });
+
+ await JobsList.visit();
- function testSingleSelectFacet(
- label,
- { facet, paramName, beforeEach, filter, expectedOptions, optionToSelect }
- ) {
- test(`the ${label} facet has the correct options`, async function (assert) {
- await facetOptions(assert, beforeEach, facet, expectedOptions);
+ await JobsList.facets.type.toggle();
+ await JobsList.facets.type.options[0].toggle();
+
+ await JobsList.runJobButton.click();
+ assert.equal(currentURL(), '/jobs/run');
+ });
+
+ module('Pagination', function () {
+ module('Buttons are appropriately disabled', function () {
+ test('when there are no jobs', async function (assert) {
+ await JobsList.visit();
+ assert.dom('[data-test-pager="first"]').doesNotExist();
+ assert.dom('[data-test-pager="previous"]').doesNotExist();
+ assert.dom('[data-test-pager="next"]').doesNotExist();
+ assert.dom('[data-test-pager="last"]').doesNotExist();
+ await percySnapshot(assert);
+ });
+ test('when there are fewer jobs than your page size setting', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
+ createJobs(server, 5);
+ await JobsList.visit();
+ assert.dom('[data-test-pager="first"]').isDisabled();
+ assert.dom('[data-test-pager="previous"]').isDisabled();
+ assert.dom('[data-test-pager="next"]').isDisabled();
+ assert.dom('[data-test-pager="last"]').isDisabled();
+ await percySnapshot(assert);
+ localStorage.removeItem('nomadPageSize');
+ });
+ test('when you have plenty of jobs', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
+ createJobs(server, 25);
+ await JobsList.visit();
+ assert.dom('.job-row').exists({ count: 10 });
+ assert.dom('[data-test-pager="first"]').isDisabled();
+ assert.dom('[data-test-pager="previous"]').isDisabled();
+ assert.dom('[data-test-pager="next"]').isNotDisabled();
+ assert.dom('[data-test-pager="last"]').isNotDisabled();
+ // Clicking next brings me to another full page
+ await click('[data-test-pager="next"]');
+ assert.dom('.job-row').exists({ count: 10 });
+ assert.dom('[data-test-pager="first"]').isNotDisabled();
+ assert.dom('[data-test-pager="previous"]').isNotDisabled();
+ assert.dom('[data-test-pager="next"]').isNotDisabled();
+ assert.dom('[data-test-pager="last"]').isNotDisabled();
+ // clicking next again brings me to the last page, showing jobs 20-25
+ await click('[data-test-pager="next"]');
+ assert.dom('.job-row').exists({ count: 5 });
+ assert.dom('[data-test-pager="first"]').isNotDisabled();
+ assert.dom('[data-test-pager="previous"]').isNotDisabled();
+ assert.dom('[data-test-pager="next"]').isDisabled();
+ assert.dom('[data-test-pager="last"]').isDisabled();
+ await percySnapshot(assert);
+ localStorage.removeItem('nomadPageSize');
+ });
});
+ module('Jobs are appropriately sorted by modify index', function () {
+ test('on a single long page', async function (assert) {
+ const jobsToCreate = 25;
+ localStorage.setItem('nomadPageSize', '25');
+ createJobs(server, jobsToCreate);
+ await JobsList.visit();
+ assert.dom('.job-row').exists({ count: 25 });
+ // Check the data-test-modify-index attribute on each row
+ let rows = document.querySelectorAll('.job-row');
+ let modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(jobsToCreate)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse(),
+ 'Jobs are sorted by modify index'
+ );
+ localStorage.removeItem('nomadPageSize');
+ });
+ test('across multiple pages', async function (assert) {
+ const jobsToCreate = 90;
+ const pageSize = 25;
+ localStorage.setItem('nomadPageSize', pageSize.toString());
+ createJobs(server, jobsToCreate);
+ await JobsList.visit();
+ let rows = document.querySelectorAll('.job-row');
+ let modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(jobsToCreate)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse()
+ .slice(0, pageSize),
+ 'First page is sorted by modify index'
+ );
+ // Click next
+ await click('[data-test-pager="next"]');
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(jobsToCreate)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse()
+ .slice(pageSize, pageSize * 2),
+ 'Second page is sorted by modify index'
+ );
- test(`the ${label} facet filters the jobs list by ${label}`, async function (assert) {
- await beforeEach();
- await facet.toggle();
+ // Click next again
+ await click('[data-test-pager="next"]');
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(jobsToCreate)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse()
+ .slice(pageSize * 2, pageSize * 3),
+ 'Third page is sorted by modify index'
+ );
- const option = facet.options.findOneBy('label', optionToSelect);
- const selection = option.key;
- await option.select();
+ // Click previous
+ await click('[data-test-pager="previous"]');
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(jobsToCreate)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse()
+ .slice(pageSize, pageSize * 2),
+ 'Second page is sorted by modify index'
+ );
+
+ // Click next twice, should be the last page, and therefore fewer than pageSize jobs
+ await click('[data-test-pager="next"]');
+ await click('[data-test-pager="next"]');
+
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(jobsToCreate)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse()
+ .slice(pageSize * 3),
+ 'Fourth page is sorted by modify index'
+ );
+ assert.equal(
+ rows.length,
+ jobsToCreate - pageSize * 3,
+ 'Last page has fewer jobs'
+ );
- const expectedJobs = server.db.jobs
- .filter((job) => filter(job, selection))
- .sortBy('modifyIndex')
- .reverse();
+ // Go back to the first page
+ await click('[data-test-pager="first"]');
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(jobsToCreate)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse()
+ .slice(0, pageSize),
+ 'First page is sorted by modify index'
+ );
- JobsList.jobs.forEach((job, index) => {
+ // Click "last" to get an even number of jobs at the end of the list
+ await click('[data-test-pager="last"]');
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(jobsToCreate)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse()
+ .slice(-pageSize),
+ 'Last page is sorted by modify index'
+ );
assert.equal(
- job.id,
- expectedJobs[index].id,
- `Job at ${index} is ${expectedJobs[index].id}`
+ rows.length,
+ pageSize,
+ 'Last page has the correct number of jobs'
);
+
+ // type "{{" to go to the beginning
+ triggerKeyEvent('.page-layout', 'keydown', '{');
+ await triggerKeyEvent('.page-layout', 'keydown', '{');
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(jobsToCreate)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse()
+ .slice(0, pageSize),
+ 'Keynav takes me back to the starting page'
+ );
+
+ // type "]]" to go forward a page
+ triggerKeyEvent('.page-layout', 'keydown', ']');
+ await triggerKeyEvent('.page-layout', 'keydown', ']');
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(jobsToCreate)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse()
+ .slice(pageSize, pageSize * 2),
+ 'Keynav takes me forward a page'
+ );
+
+ localStorage.removeItem('nomadPageSize');
});
});
+ module('Live updates are reflected in the list', function () {
+ test('When you have live updates enabled, the list updates when new jobs are created', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
+ createJobs(server, 10);
+ await JobsList.visit();
+ assert.dom('.job-row').exists({ count: 10 });
+ let rows = document.querySelectorAll('.job-row');
+ assert.equal(rows.length, 10, 'List is still 10 rows');
+ let modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(10)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse(),
+ 'Jobs are sorted by modify index'
+ );
+ assert.dom('[data-test-pager="next"]').isDisabled();
- test(`selecting an option in the ${label} facet updates the ${paramName} query param`, async function (assert) {
- await beforeEach();
- await facet.toggle();
+ // Create a new job
+ server.create('job', {
+ namespaceId: 'default',
+ resourceSpec: Array(1).fill('M: 256, C: 500'),
+ groupAllocCount: 1,
+ modifyIndex: 11,
+ createAllocations: false,
+ shallow: true,
+ name: 'new-job',
+ });
- const option = facet.options.objectAt(1);
- const selection = option.key;
- await option.select();
+ const controller = this.owner.lookup('controller:jobs.index');
+
+ let currentParams = {
+ per_page: 10,
+ };
+
+ // We have to wait for watchJobIDs to trigger the "dueling query" with watchJobs.
+ // Since we can't await the watchJobs promise, we set a reasonably short timeout
+ // to check the state of the list after the dueling query has completed.
+ await controller.watchJobIDs.perform(currentParams, 0);
+
+ let updatedJob = assert.async(); // watch for this to say "My tests oughta be passing by now"
+ const duelingQueryUpdateTime = 200;
+
+ assert.timeout(500);
+
+ setTimeout(async () => {
+ // Order should now be 11-2
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(10)
+ .fill()
+ .map((_, i) => i + 2)
+ .reverse(),
+ 'Jobs are sorted by modify index'
+ );
+
+ // Simulate one of the on-page jobs getting its modify-index bumped. It should bump to the top of the list.
+ let existingJobToUpdate = server.db.jobs.findBy(
+ (job) => job.modifyIndex === 5
+ );
+ server.db.jobs.update(existingJobToUpdate.id, { modifyIndex: 12 });
+ await controller.watchJobIDs.perform(currentParams, 0);
+ let updatedOnPageJob = assert.async();
+
+ setTimeout(async () => {
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ [12, 11, 10, 9, 8, 7, 6, 4, 3, 2],
+ 'Jobs are sorted by modify index, on-page job moves up to the top, and off-page pending'
+ );
+ updatedOnPageJob();
+
+ assert.dom('[data-test-pager="next"]').isNotDisabled();
+
+ await click('[data-test-pager="next"]');
+
+ rows = document.querySelectorAll('.job-row');
+ assert.equal(rows.length, 1, 'List is now 1 row');
+ assert.equal(
+ rows[0].getAttribute('data-test-modify-index'),
+ '1',
+ 'Job is the first job, now pushed to the second page'
+ );
+ }, duelingQueryUpdateTime);
+ updatedJob();
+ }, duelingQueryUpdateTime);
+
+ localStorage.removeItem('nomadPageSize');
+ });
+ test('When you have live updates disabled, the list does not update, but prompts you to refresh', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
+ localStorage.setItem('nomadLiveUpdateJobsIndex', 'false');
+ createJobs(server, 10);
+ await JobsList.visit();
+ assert.dom('[data-test-updates-pending-button]').doesNotExist();
+
+ let rows = document.querySelectorAll('.job-row');
+ assert.equal(rows.length, 10, 'List is still 10 rows');
+ let modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(10)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse(),
+ 'Jobs are sorted by modify index'
+ );
- assert.ok(
- currentURL().includes(`${paramName}=${selection}`),
- 'URL has the correct query param key and value'
- );
- });
- }
+ // Create a new job
+ server.create('job', {
+ namespaceId: 'default',
+ resourceSpec: Array(1).fill('M: 256, C: 500'),
+ groupAllocCount: 1,
+ modifyIndex: 11,
+ createAllocations: false,
+ shallow: true,
+ name: 'new-job',
+ });
- function testFacet(
- label,
- { facet, paramName, beforeEach, filter, expectedOptions }
- ) {
- test(`the ${label} facet has the correct options`, async function (assert) {
- await facetOptions(assert, beforeEach, facet, expectedOptions);
+ const controller = this.owner.lookup('controller:jobs.index');
+
+ let currentParams = {
+ per_page: 10,
+ };
+
+ // We have to wait for watchJobIDs to trigger the "dueling query" with watchJobs.
+ // Since we can't await the watchJobs promise, we set a reasonably short timeout
+ // to check the state of the list after the dueling query has completed.
+ await controller.watchJobIDs.perform(currentParams, 0);
+
+ let updatedUnshownJob = assert.async(); // watch for this to say "My tests oughta be passing by now"
+ const duelingQueryUpdateTime = 200;
+
+ assert.timeout(500);
+
+ setTimeout(async () => {
+ // Order should still be be 10-1
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ Array(10)
+ .fill()
+ .map((_, i) => i + 1)
+ .reverse(),
+ 'Jobs are sorted by modify index, off-page job not showing up yet'
+ );
+ assert
+ .dom('[data-test-updates-pending-button]')
+ .exists('The refresh button is present');
+ assert
+ .dom('[data-test-pager="next"]')
+ .isNotDisabled(
+ 'Next button is enabled in spite of the new job not showing up yet'
+ );
+
+ // Simulate one of the on-page jobs getting its modify-index bumped. It should remain in place.
+ let existingJobToUpdate = server.db.jobs.findBy(
+ (job) => job.modifyIndex === 5
+ );
+ server.db.jobs.update(existingJobToUpdate.id, { modifyIndex: 12 });
+ await controller.watchJobIDs.perform(currentParams, 0);
+ let updatedShownJob = assert.async();
+
+ setTimeout(async () => {
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ [10, 9, 8, 7, 6, 12, 4, 3, 2, 1],
+ 'Jobs are sorted by modify index, on-page job remains in-place, and off-page pending'
+ );
+ assert
+ .dom('[data-test-updates-pending-button]')
+ .exists('The refresh button is still present');
+ assert
+ .dom('[data-test-pager="next"]')
+ .isNotDisabled('Next button is still enabled');
+
+ // Click the refresh button
+ await click('[data-test-updates-pending-button]');
+ rows = document.querySelectorAll('.job-row');
+ modifyIndexes = Array.from(rows).map((row) =>
+ parseInt(row.getAttribute('data-test-modify-index'))
+ );
+ assert.deepEqual(
+ modifyIndexes,
+ [12, 11, 10, 9, 8, 7, 6, 4, 3, 2],
+ 'Jobs are sorted by modify index, after refresh'
+ );
+ assert
+ .dom('[data-test-updates-pending-button]')
+ .doesNotExist('The refresh button is gone');
+ updatedShownJob();
+ }, duelingQueryUpdateTime);
+ updatedUnshownJob();
+ }, duelingQueryUpdateTime);
+
+ localStorage.removeItem('nomadPageSize');
+ localStorage.removeItem('nomadLiveUpdateJobsIndex');
+ });
});
+ });
- test(`the ${label} facet filters the jobs list by ${label}`, async function (assert) {
- let option;
+ module('Searching and Filtering', function () {
+ module('Search', function () {
+ test('Searching reasons about whether you intended a job name or a filter expression', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
+ createJobs(server, 10);
+ await JobsList.visit();
+
+ await JobsList.search.fillIn('something-that-surely-doesnt-exist');
+ // check to see that we fired off a request; check handledRequests to find one with a ?filter in it
+ assert.ok(
+ server.pretender.handledRequests.find((req) =>
+ decodeURIComponent(req.url).includes(
+ '?filter=Name contains something-that-surely-doesnt-exist'
+ )
+ ),
+ 'A request was made with a filter query param that assumed job name'
+ );
- await beforeEach();
- await facet.toggle();
+ await JobsList.search.fillIn('Namespace == ns-2');
- option = facet.options.objectAt(0);
- await option.toggle();
+ assert.ok(
+ server.pretender.handledRequests.find((req) =>
+ decodeURIComponent(req.url).includes('?filter=Namespace == ns-2')
+ ),
+ 'A request was made with a filter query param for a filter expression as typed'
+ );
- const selection = [option.key];
- const expectedJobs = server.db.jobs
- .filter((job) => filter(job, selection))
- .sortBy('modifyIndex')
- .reverse();
+ localStorage.removeItem('nomadPageSize');
+ });
- JobsList.jobs.forEach((job, index) => {
- assert.equal(
- job.id,
- expectedJobs[index].id,
- `Job at ${index} is ${expectedJobs[index].id}`
- );
+ test('Searching by name filters the list', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
+ createJobs(server, 10);
+ server.create('job', {
+ name: 'hashi-one',
+ id: 'hashi-one',
+ modifyIndex: 0,
+ });
+ server.create('job', {
+ name: 'hashi-two',
+ id: 'hashi-two',
+ modifyIndex: 0,
+ });
+ await JobsList.visit();
+
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 10 },
+ 'Initially, 10 jobs are listed without any filters.'
+ );
+ assert
+ .dom('[data-test-job-row="hashi-one"]')
+ .doesNotExist(
+ 'The specific job hashi-one should not appear without filtering.'
+ );
+ assert
+ .dom('[data-test-job-row="hashi-two"]')
+ .doesNotExist(
+ 'The specific job hashi-two should also not appear without filtering.'
+ );
+
+ await JobsList.search.fillIn('hashi-one');
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 1 },
+ 'Only one job should be visible when filtering by the name "hashi-one".'
+ );
+ assert
+ .dom('[data-test-job-row="hashi-one"]')
+ .exists(
+ 'The job hashi-one appears as expected when filtered by name.'
+ );
+ assert
+ .dom('[data-test-job-row="hashi-two"]')
+ .doesNotExist(
+ 'The job hashi-two should not appear when filtering by "hashi-one".'
+ );
+
+ await JobsList.search.fillIn('hashi');
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 2 },
+ 'Two jobs should appear when the filter "hashi" matches both job names.'
+ );
+ assert
+ .dom('[data-test-job-row="hashi-one"]')
+ .exists(
+ 'Job hashi-one is correctly displayed under the "hashi" filter.'
+ );
+ assert
+ .dom('[data-test-job-row="hashi-two"]')
+ .exists(
+ 'Job hashi-two is correctly displayed under the "hashi" filter.'
+ );
+
+ await JobsList.search.fillIn('Name == hashi');
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 0 },
+ 'No jobs should appear when an incorrect filter format "Name == hashi" is used.'
+ );
+
+ await JobsList.search.fillIn('');
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 10 },
+ 'All jobs reappear when the search filter is cleared.'
+ );
+ assert
+ .dom('[data-test-job-row="hashi-one"]')
+ .doesNotExist(
+ 'The job hashi-one should disappear again when the filter is cleared.'
+ );
+ assert
+ .dom('[data-test-job-row="hashi-two"]')
+ .doesNotExist(
+ 'The job hashi-two should disappear again when the filter is cleared.'
+ );
+
+ localStorage.removeItem('nomadPageSize');
+ });
+
+ test('Searching by type filters the list', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
+ server.createList('job', 10, {
+ createAllocations: false,
+ type: 'service',
+ modifyIndex: 10,
+ });
+
+ server.create('job', {
+ id: 'batch-job',
+ type: 'batch',
+ createAllocations: false,
+ modifyIndex: 9,
+ });
+ server.create('job', {
+ id: 'system-job',
+ type: 'system',
+ createAllocations: false,
+ modifyIndex: 9,
+ });
+ server.create('job', {
+ id: 'sysbatch-job',
+ type: 'sysbatch',
+ createAllocations: false,
+ modifyIndex: 9,
+ });
+ server.create('job', {
+ id: 'sysbatch-job-2',
+ type: 'sysbatch',
+ createAllocations: false,
+ modifyIndex: 9,
+ });
+
+ await JobsList.visit();
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 10 },
+ 'Initial setup should show 10 jobs of type "service".'
+ );
+ assert
+ .dom('[data-test-job-type="service"]')
+ .exists(
+ { count: 10 },
+ 'All initial jobs are confirmed to be of type "service".'
+ );
+
+ await JobsList.search.fillIn('Type == batch');
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 1 },
+ 'Filtering by "Type == batch" should show exactly one job.'
+ );
+ assert
+ .dom('[data-test-job-type="batch"]')
+ .exists(
+ { count: 1 },
+ 'The single job of type "batch" is displayed as expected.'
+ );
+
+ await JobsList.search.fillIn('Type == system');
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 1 },
+ 'Only one job should be displayed when filtering by "Type == system".'
+ );
+ assert
+ .dom('[data-test-job-type="system"]')
+ .exists(
+ { count: 1 },
+ 'The job of type "system" appears as expected.'
+ );
+
+ await JobsList.search.fillIn('Type == sysbatch');
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 2 },
+ 'Two jobs should be visible under the filter "Type == sysbatch".'
+ );
+ assert
+ .dom('[data-test-job-type="sysbatch"]')
+ .exists(
+ { count: 2 },
+ 'Both jobs of type "sysbatch" are correctly displayed.'
+ );
+
+ await JobsList.search.fillIn('Type contains sys');
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 3 },
+ 'Filter "Type contains sys" should show three jobs.'
+ );
+ assert
+ .dom('[data-test-job-type="sysbatch"]')
+ .exists(
+ { count: 2 },
+ 'Two jobs of type "sysbatch" match the "sys" substring.'
+ );
+ assert
+ .dom('[data-test-job-type="system"]')
+ .exists(
+ { count: 1 },
+ 'One job of type "system" matches the "sys" substring.'
+ );
+
+ await JobsList.search.fillIn('Type != service');
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 4 },
+ 'Four jobs should be visible when excluding type "service".'
+ );
+ assert
+ .dom('[data-test-job-type="batch"]')
+ .exists({ count: 1 }, 'One batch job is visible.');
+ assert
+ .dom('[data-test-job-type="system"]')
+ .exists({ count: 1 }, 'One system job is visible.');
+ assert
+ .dom('[data-test-job-type="sysbatch"]')
+ .exists({ count: 2 }, 'Two sysbatch jobs are visible.');
+
+ // Next/Last buttons are disabled when searching for the 10 services bc there's just 10
+ await JobsList.search.fillIn('Type == service');
+ assert.dom('.job-row').exists({ count: 10 });
+ assert.dom('[data-test-job-type="service"]').exists({ count: 10 });
+ assert
+ .dom('[data-test-pager="next"]')
+ .isDisabled(
+ 'The next page button should be disabled when all jobs fit on one page.'
+ );
+ assert
+ .dom('[data-test-pager="last"]')
+ .isDisabled(
+ 'The last page button should also be disabled under the same conditions.'
+ );
+
+ // But if we disinclude sysbatch we'll have 12, so next/last should be clickable
+ await JobsList.search.fillIn('Type != sysbatch');
+ assert.dom('.job-row').exists({ count: 10 });
+ assert
+ .dom('[data-test-pager="next"]')
+ .isNotDisabled(
+ 'The next page button should be enabled when not all jobs are shown on one page.'
+ );
+ assert
+ .dom('[data-test-pager="last"]')
+ .isNotDisabled('The last page button should be enabled as well.');
+
+ localStorage.removeItem('nomadPageSize');
});
});
+ module('Filtering', function () {
+ test('Filtering by namespace filters the list', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
- test(`selecting multiple options in the ${label} facet results in a broader search`, async function (assert) {
- const selection = [];
+ server.create('namespace', {
+ id: 'default',
+ name: 'default',
+ });
- await beforeEach();
- await facet.toggle();
+ server.create('namespace', {
+ id: 'ns-2',
+ name: 'ns-2',
+ });
- const option1 = facet.options.objectAt(0);
- const option2 = facet.options.objectAt(1);
- await option1.toggle();
- selection.push(option1.key);
- await option2.toggle();
- selection.push(option2.key);
+ server.createList('job', 10, {
+ createAllocations: false,
+ namespaceId: 'default',
+ modifyIndex: 10,
+ });
- const expectedJobs = server.db.jobs
- .filter((job) => filter(job, selection))
- .sortBy('modifyIndex')
- .reverse();
+ server.create('job', {
+ id: 'ns-2-job',
+ namespaceId: 'ns-2',
+ createAllocations: false,
+ modifyIndex: 9,
+ });
- JobsList.jobs.forEach((job, index) => {
- assert.equal(
- job.id,
- expectedJobs[index].id,
- `Job at ${index} is ${expectedJobs[index].id}`
- );
+ // By default, start on "All" namespace
+ await JobsList.visit();
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 10 },
+ 'Initial setup should show 10 jobs in the default namespace.'
+ );
+ assert
+ .dom('[data-test-job-row="ns-2-job"]')
+ .doesNotExist(
+ 'The job in the ns-2 namespace should not appear without filtering.'
+ );
+
+ assert
+ .dom('[data-test-pager="next"]')
+ .isNotDisabled(
+ '11 jobs on "All" namespace, so second page is available'
+ );
+
+ // Toggle ns-2 namespace
+ await JobsList.facets.namespace.toggle();
+ await JobsList.facets.namespace.options[2].toggle();
+
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 1 },
+ 'Only one job should be visible when filtering by the ns-2 namespace.'
+ );
+ assert
+ .dom('[data-test-job-row="ns-2-job"]')
+ .exists(
+ 'The job in the ns-2 namespace appears as expected when filtered.'
+ );
+
+ // Switch to default namespace
+ await JobsList.facets.namespace.toggle();
+ await JobsList.facets.namespace.options[1].toggle();
+
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 10 },
+ 'All jobs reappear when the search filter is cleared.'
+ );
+ assert
+ .dom('[data-test-job-row="ns-2-job"]')
+ .doesNotExist(
+ 'The job in the ns-2 namespace should disappear when the filter is cleared.'
+ );
+
+ assert
+ .dom('[data-test-pager="next"]')
+ .isDisabled(
+ '10 jobs in "Default" namespace, so second page is not available'
+ );
+
+ localStorage.removeItem('nomadPageSize');
});
- });
+ test('Namespace filter only shows up if the server has more than one namespace', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
- test(`selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) {
- const selection = [];
+ server.create('namespace', {
+ id: 'default',
+ name: 'default',
+ });
- await beforeEach();
- await facet.toggle();
+ server.createList('job', 10, {
+ createAllocations: false,
+ namespaceId: 'default',
+ modifyIndex: 10,
+ });
- const option1 = facet.options.objectAt(0);
- const option2 = facet.options.objectAt(1);
- await option1.toggle();
- selection.push(option1.key);
- await option2.toggle();
- selection.push(option2.key);
+ await JobsList.visit();
+ assert
+ .dom('[data-test-facet="Namespace"]')
+ .doesNotExist(
+ 'Namespace filter should not appear with only one namespace.'
+ );
- assert.ok(
- currentURL().includes(encodeURIComponent(JSON.stringify(selection))),
- 'URL has the correct query param key and value'
- );
- });
+ let system = this.owner.lookup('service:system');
+ system.shouldShowNamespaces = true;
+
+ await settled();
+
+ assert
+ .dom('[data-test-facet="Namespace"]')
+ .exists(
+ 'Namespace filter should appear with more than one namespace.'
+ );
+
+ localStorage.removeItem('nomadPageSize');
+ });
+ test('Filtering by status filters the list', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
+ server.createList('job', 10, {
+ createAllocations: false,
+ status: 'running',
+ modifyIndex: 10,
+ });
+
+ server.create('job', {
+ id: 'pending-job',
+ status: 'pending',
+ createAllocations: false,
+ modifyIndex: 9,
+ });
- test('the run job button works when filters are set', async function (assert) {
- ['pre-one', 'pre-two', 'pre-three'].forEach((name) => {
server.create('job', {
- name,
+ id: 'dead-job',
+ status: 'dead',
createAllocations: false,
- childrenCount: 0,
+ modifyIndex: 8,
});
+
+ await JobsList.visit();
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 10 },
+ 'Initial setup should show 10 jobs in the "running" status.'
+ );
+ assert
+ .dom('[data-test-job-row="pending-job"]')
+ .doesNotExist(
+ 'The job in the "pending" status should not appear without filtering.'
+ );
+ assert
+ .dom('[data-test-pager="next"]')
+ .isNotDisabled(
+ '10 jobs in "running" status, so second page is available'
+ );
+
+ await JobsList.facets.status.toggle();
+ await JobsList.facets.status.options[0].toggle(); // pending
+
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 1 },
+ 'Only one job should be visible when filtering by the "pending" status.'
+ );
+ assert
+ .dom('[data-test-job-row="pending-job"]')
+ .exists(
+ 'The job in the "pending" status appears as expected when filtered.'
+ );
+
+ assert
+ .dom('[data-test-pager="next"]')
+ .isDisabled(
+ '1 job in "pending" status, so second page is not available'
+ );
+
+ await JobsList.facets.status.options[2].toggle(); // dead
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 2 },
+ 'Two jobs should be visible when the "dead" filter is added'
+ );
+ assert
+ .dom('[data-test-job-row="dead-job"]')
+ .exists(
+ { count: 1 },
+ 'The job in the "dead" status appears as expected when filtered.'
+ );
+
+ localStorage.removeItem('nomadPageSize');
});
- await JobsList.visit();
+ test('Filtering by a dynamically-generated facet: data-test-facet="Node Pool"', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
+
+ server.create('node-pool', {
+ id: 'pool-1',
+ name: 'pool-1',
+ });
+ server.create('node-pool', {
+ id: 'pool-2',
+ name: 'pool-2',
+ });
+
+ server.createList('job', 10, {
+ createAllocations: false,
+ nodePool: 'pool-1',
+ modifyIndex: 10,
+ });
+
+ server.create('job', {
+ id: 'pool-2-job',
+ nodePool: 'pool-2',
+ createAllocations: false,
+ modifyIndex: 9,
+ });
+
+ await JobsList.visit();
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 10 },
+ 'Initial setup should show 10 jobs in the "pool-1" node pool.'
+ );
+ assert
+ .dom('[data-test-job-row="pool-2-job"]')
+ .doesNotExist(
+ 'The job in the "pool-2" node pool should not appear without filtering.'
+ );
+ await JobsList.facets.nodePool.toggle();
+
+ await JobsList.facets.nodePool.options[2].toggle(); // pool-2
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 1 },
+ 'Only one job should be visible when filtering by the "pool-2" node pool.'
+ );
+ assert
+ .dom('[data-test-job-row="pool-2-job"]')
+ .exists(
+ 'The job in the "pool-2" node pool appears as expected when filtered.'
+ );
+
+ localStorage.removeItem('nomadPageSize');
+ });
+
+ test('Combined Filtering and Searching', async function (assert) {
+ localStorage.setItem('nomadPageSize', '10');
+ // 2 service, 1 batch, 1 system, 1 sysbatch
+ // 3 running, 1 dead, 1 pending
+ server.create('job', {
+ id: 'job1',
+ name: 'Alpha Processing',
+ type: 'batch',
+ status: 'running',
+ });
+ server.create('job', {
+ id: 'job2',
+ name: 'Beta Calculation',
+ type: 'service',
+ status: 'dead',
+ });
+ server.create('job', {
+ id: 'job3',
+ name: 'Gamma Analysis',
+ type: 'sysbatch',
+ status: 'pending',
+ });
+ server.create('job', {
+ id: 'job4',
+ name: 'Delta Research',
+ type: 'system',
+ status: 'running',
+ });
+ server.create('job', {
+ id: 'job5',
+ name: 'Epsilon Development',
+ type: 'service',
+ status: 'running',
+ });
- await JobsList.facets.prefix.toggle();
- await JobsList.facets.prefix.options[0].toggle();
+ // All 5 jobs show up by default
+ await JobsList.visit();
+ assert.dom('.job-row').exists({ count: 5 }, 'All 5 jobs are visible');
+
+ // Toggle type to "service", should see 2 jobs
+ await JobsList.facets.type.toggle();
+ await JobsList.facets.type.options[1].toggle();
+ assert
+ .dom('.job-row')
+ .exists({ count: 2 }, 'Two service jobs are visible');
+
+ // additionally, enable "batch" type
+ await JobsList.facets.type.options[0].toggle();
+ assert
+ .dom('.job-row')
+ .exists(
+ { count: 3 },
+ 'Three jobs are visible with service and batch types'
+ );
+ assert.dom('[data-test-job-row="job1"]').exists();
+ assert.dom('[data-test-job-row="job2"]').exists();
+ assert.dom('[data-test-job-row="job5"]').exists();
+
+ // additionally, enable "running" status to filter down to just the running ones
+ await JobsList.facets.status.toggle();
+ await JobsList.facets.status.options[1].toggle();
+ assert
+ .dom('.job-row')
+ .exists({ count: 2 }, 'Two running service/batch jobs are visible');
+ assert.dom('[data-test-job-row="job1"]').exists();
+ assert.dom('[data-test-job-row="job5"]').exists();
+ assert.dom('[data-test-job-row="job2"]').doesNotExist();
+
+ // additionally, perform a search for Name != "Alpha Processing"
+ await JobsList.search.fillIn('Name != "Alpha Processing"');
+ assert
+ .dom('.job-row')
+ .exists({ count: 1 }, 'One running service job is visible');
+ assert.dom('[data-test-job-row="job5"]').exists();
+ assert.dom('[data-test-job-row="job1"]').doesNotExist();
+ });
+ });
+ });
+});
- await JobsList.runJobButton.click();
- assert.equal(currentURL(), '/jobs/run');
+/**
+ *
+ * @param {*} server
+ * @param {number} jobsToCreate
+ */
+function createJobs(server, jobsToCreate) {
+ for (let i = 0; i < jobsToCreate; i++) {
+ server.create('job', {
+ namespaceId: 'default',
+ resourceSpec: Array(1).fill('M: 256, C: 500'),
+ groupAllocCount: 1,
+ modifyIndex: i + 1,
+ createAllocations: false,
+ shallow: true,
});
}
-});
+}
+
+async function facetOptions(assert, beforeEach, facet, expectedOptions) {
+ await beforeEach();
+ await facet.toggle();
+
+ let expectation;
+ if (typeof expectedOptions === 'function') {
+ expectation = expectedOptions(server.db.jobs);
+ } else {
+ expectation = expectedOptions;
+ }
+
+ assert.deepEqual(
+ facet.options.map((option) => option.label.trim()),
+ expectation,
+ 'Options for facet are as expected'
+ );
+}
+
+function testFacet(
+ label,
+ { facet, paramName, beforeEach, filter, expectedOptions }
+) {
+ test(`the ${label} facet has the correct options`, async function (assert) {
+ await facetOptions(assert, beforeEach, facet, expectedOptions);
+ });
+
+ test(`the ${label} facet filters the jobs list by ${label}`, async function (assert) {
+ let option;
+
+ await beforeEach();
+ await facet.toggle();
+
+ option = facet.options.objectAt(0);
+ await option.toggle();
+
+ const selection = [option.label];
+ const expectedJobs = server.db.jobs
+ .filter((job) => filter(job, selection))
+ .sortBy('modifyIndex')
+ .reverse();
+
+ JobsList.jobs.forEach((job, index) => {
+ assert.equal(
+ job.id,
+ expectedJobs[index].id,
+ `Job at ${index} is ${expectedJobs[index].id}`
+ );
+ });
+ });
+
+ test(`selecting multiple options in the ${label} facet results in a broader search`, async function (assert) {
+ const selection = [];
+
+ await beforeEach();
+ await facet.toggle();
+
+ const option1 = facet.options.objectAt(0);
+ const option2 = facet.options.objectAt(1);
+ await option1.toggle();
+ selection.push(option1.label);
+ await option2.toggle();
+ selection.push(option2.label);
+
+ const expectedJobs = server.db.jobs
+ .filter((job) => filter(job, selection))
+ .sortBy('modifyIndex')
+ .reverse();
+
+ JobsList.jobs.forEach((job, index) => {
+ assert.equal(
+ job.id,
+ expectedJobs[index].id,
+ `Job at ${index} is ${expectedJobs[index].id}`
+ );
+ });
+ });
+
+ test(`selecting options in the ${label} facet updates the ${paramName} query param`, async function (assert) {
+ const selection = [];
+
+ await beforeEach();
+ await facet.toggle();
+
+ const option1 = facet.options.objectAt(0);
+ const option2 = facet.options.objectAt(1);
+ await option1.toggle();
+ selection.push(option1.label);
+ await option2.toggle();
+ selection.push(option2.label);
+
+ selection.forEach((selection) => {
+ let capitalizedParamName =
+ paramName.charAt(0).toUpperCase() + paramName.slice(1);
+ assert.ok(
+ currentURL().includes(
+ encodeURIComponent(`${capitalizedParamName} == ${selection}`)
+ ),
+ `URL has the correct query param key and value for ${selection}`
+ );
+ });
+ });
+}
+
+function testSingleSelectFacet(
+ label,
+ { facet, paramName, beforeEach, filter, expectedOptions, optionToSelect }
+) {
+ test(`the ${label} facet has the correct options`, async function (assert) {
+ await facetOptions(assert, beforeEach, facet, expectedOptions);
+ });
+
+ test(`the ${label} facet filters the jobs list by ${label}`, async function (assert) {
+ await beforeEach();
+ await facet.toggle();
+
+ const option = facet.options.findOneBy('label', optionToSelect);
+ const selection = option.label;
+ await option.toggle();
+
+ const expectedJobs = server.db.jobs
+ .filter((job) => filter(job, selection))
+ .sortBy('modifyIndex')
+ .reverse();
+
+ JobsList.jobs.forEach((job, index) => {
+ assert.equal(
+ job.id,
+ expectedJobs[index].id,
+ `Job at ${index} is ${expectedJobs[index].id}`
+ );
+ });
+ });
+
+ test(`selecting an option in the ${label} facet updates the ${paramName} query param`, async function (assert) {
+ await beforeEach();
+ await facet.toggle();
+
+ const option = facet.options.objectAt(1);
+ const selection = option.label;
+ await option.toggle();
+
+ assert.ok(
+ currentURL().includes(`${paramName}=${selection}`),
+ 'URL has the correct query param key and value'
+ );
+ });
+}
diff --git a/ui/tests/acceptance/keyboard-test.js b/ui/tests/acceptance/keyboard-test.js
index c22d07e9b17b..77653e708772 100644
--- a/ui/tests/acceptance/keyboard-test.js
+++ b/ui/tests/acceptance/keyboard-test.js
@@ -255,10 +255,13 @@ module('Acceptance | keyboard', function (hooks) {
await visit('/');
await triggerEvent('.page-layout', 'keydown', { key: 'Shift' });
+
+ let keyboardService = this.owner.lookup('service:keyboard');
+ let hints = keyboardService.keyCommands.filter((c) => c.element);
assert.equal(
document.querySelectorAll('[data-test-keyboard-hint]').length,
- 7,
- 'Shows 7 hints by default'
+ hints.length,
+ 'Shows correct number of hints by default'
);
await triggerEvent('.page-layout', 'keyup', { key: 'Shift' });
@@ -333,7 +336,7 @@ module('Acceptance | keyboard', function (hooks) {
let token = server.create('token', { type: 'management' });
window.localStorage.nomadTokenSecret = token.secretId;
server.createList('job', 3, { createAllocations: true, type: 'system' });
- const jobID = server.db.jobs.sortBy('modifyIndex').reverse()[0].id;
+ const jobID = server.db.jobs[0].id;
await visit(`/jobs/${jobID}@default`);
await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js
index e73a444dd420..ed0aaa9aaa0b 100644
--- a/ui/tests/acceptance/token-test.js
+++ b/ui/tests/acceptance/token-test.js
@@ -194,12 +194,11 @@ module('Acceptance | tokens', function (hooks) {
await Tokens.visit();
await Tokens.secret(secretId).submit();
- server.pretender.get('/v1/jobs', function () {
+ server.pretender.get('/v1/jobs/statuses', function () {
return [200, {}, '[]'];
});
await Jobs.visit();
-
// If jobs are lingering in the store, they would show up
assert.notOk(find('[data-test-job-row]'), 'No jobs found');
});
@@ -272,7 +271,7 @@ module('Acceptance | tokens', function (hooks) {
},
],
};
- server.pretender.get('/v1/jobs', function () {
+ server.pretender.get('/v1/jobs/statuses', function () {
return [500, {}, JSON.stringify(expiredServerError)];
});
@@ -298,7 +297,7 @@ module('Acceptance | tokens', function (hooks) {
},
],
};
- server.pretender.get('/v1/jobs', function () {
+ server.pretender.get('/v1/jobs/statuses', function () {
return [500, {}, JSON.stringify(notFoundServerError)];
});
@@ -843,8 +842,7 @@ module('Acceptance | tokens', function (hooks) {
// Pop over to the jobs page and make sure the Run button is disabled
await visit('/jobs');
- assert.dom('[data-test-run-job]').hasTagName('button');
- assert.dom('[data-test-run-job]').isDisabled();
+ assert.dom('[data-test-run-job]').hasAttribute('disabled');
// Sign out, and sign back in as a high-level role token
await Tokens.visit();
diff --git a/ui/tests/integration/components/job-search-box-test.js b/ui/tests/integration/components/job-search-box-test.js
new file mode 100644
index 000000000000..c1b0bb60a8c0
--- /dev/null
+++ b/ui/tests/integration/components/job-search-box-test.js
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { fillIn, find, triggerEvent } from '@ember/test-helpers';
+import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
+
+const DEBOUNCE_MS = 500;
+
+module('Integration | Component | job-search-box', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('debouncer debounces appropriately', async function (assert) {
+ assert.expect(5);
+
+ let message = '';
+
+ this.set('externalAction', (value) => {
+ message = value;
+ });
+
+ await render(
+ hbs``
+ );
+ await componentA11yAudit(this.element, assert);
+
+ const element = find('input');
+ await fillIn('input', 'test1');
+ assert.equal(message, 'test1', 'Initial typing');
+ element.value += ' wont be ';
+ triggerEvent('input', 'input');
+ assert.equal(
+ message,
+ 'test1',
+ 'Typing has happened within debounce window'
+ );
+ element.value += 'seen ';
+ triggerEvent('input', 'input');
+ await delay(DEBOUNCE_MS - 100);
+ assert.equal(
+ message,
+ 'test1',
+ 'Typing has happened within debounce window, albeit a little slower'
+ );
+ element.value += 'until now.';
+ triggerEvent('input', 'input');
+ await delay(DEBOUNCE_MS + 100);
+ assert.equal(
+ message,
+ 'test1 wont be seen until now.',
+ 'debounce window has closed'
+ );
+ });
+});
+
+function delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/ui/tests/pages/components/facet.js b/ui/tests/pages/components/facet.js
index a77c2eae1fb5..1583c8965d92 100644
--- a/ui/tests/pages/components/facet.js
+++ b/ui/tests/pages/components/facet.js
@@ -44,3 +44,16 @@ export const singleFacet = (scope) => ({
},
}),
});
+
+export const hdsFacet = (scope) => ({
+ scope,
+
+ toggle: clickable('.hds-dropdown-toggle-button'),
+
+ options: collection('.hds-dropdown-list-item', {
+ resetScope: true,
+ label: text(),
+ key: attribute('data-test-hds-facet-option'),
+ toggle: clickable('.hds-dropdown-list-item__label'),
+ }),
+});
diff --git a/ui/tests/pages/jobs/list.js b/ui/tests/pages/jobs/list.js
index eca2ac765c05..eb5a071c54ac 100644
--- a/ui/tests/pages/jobs/list.js
+++ b/ui/tests/pages/jobs/list.js
@@ -9,13 +9,12 @@ import {
collection,
clickable,
isPresent,
- property,
text,
triggerable,
visitable,
} from 'ember-cli-page-object';
-import { multiFacet, singleFacet } from 'nomad-ui/tests/pages/components/facet';
+import { hdsFacet } from 'nomad-ui/tests/pages/components/facet';
import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select';
export default create({
@@ -24,13 +23,13 @@ export default create({
visit: visitable('/jobs'),
search: {
- scope: '[data-test-jobs-search] input',
+ scope: '[data-test-jobs-search]',
keydown: triggerable('keydown'),
},
runJobButton: {
scope: '[data-test-run-job]',
- isDisabled: property('disabled'),
+ isDisabled: attribute('disabled'),
},
jobs: collection('[data-test-job-row]', {
@@ -41,17 +40,12 @@ export default create({
nodePool: text('[data-test-job-node-pool]'),
status: text('[data-test-job-status]'),
type: text('[data-test-job-type]'),
- priority: text('[data-test-job-priority]'),
- taskGroups: text('[data-test-job-task-groups]'),
hasNamespace: isPresent('[data-test-job-namespace]'),
clickRow: clickable(),
clickName: clickable('[data-test-job-name] a'),
}),
- nextPage: clickable('[data-test-pager="next"]'),
- prevPage: clickable('[data-test-pager="prev"]'),
-
isEmpty: isPresent('[data-test-empty-jobs-list]'),
emptyState: {
headline: text('[data-test-empty-jobs-list-headline]'),
@@ -70,10 +64,9 @@ export default create({
pageSizeSelect: pageSizeSelect(),
facets: {
- namespace: singleFacet('[data-test-namespace-facet]'),
- type: multiFacet('[data-test-type-facet]'),
- status: multiFacet('[data-test-status-facet]'),
- datacenter: multiFacet('[data-test-datacenter-facet]'),
- prefix: multiFacet('[data-test-prefix-facet]'),
+ namespace: hdsFacet('[data-test-facet="Namespace"]'),
+ type: hdsFacet('[data-test-facet="Type"]'),
+ status: hdsFacet('[data-test-facet="Status"]'),
+ nodePool: hdsFacet('[data-test-facet="NodePool"]'),
},
});
diff --git a/ui/tests/unit/serializers/allocation-test.js b/ui/tests/unit/serializers/allocation-test.js
index 111966566efb..dbe8dc35a908 100644
--- a/ui/tests/unit/serializers/allocation-test.js
+++ b/ui/tests/unit/serializers/allocation-test.js
@@ -48,6 +48,7 @@ module('Unit | Serializer | Allocation', function (hooks) {
name: 'testTask',
state: 'running',
failed: false,
+ events: [],
},
],
wasPreempted: false,
@@ -116,11 +117,13 @@ module('Unit | Serializer | Allocation', function (hooks) {
name: 'one.two',
state: 'running',
failed: false,
+ events: [],
},
{
name: 'three.four',
state: 'pending',
failed: true,
+ events: [],
},
],
wasPreempted: false,
@@ -190,6 +193,7 @@ module('Unit | Serializer | Allocation', function (hooks) {
name: 'task',
state: 'running',
failed: false,
+ events: [],
},
],
wasPreempted: true,
@@ -278,6 +282,7 @@ module('Unit | Serializer | Allocation', function (hooks) {
name: 'task',
state: 'running',
failed: false,
+ events: [],
},
],
wasPreempted: false,
@@ -352,11 +357,13 @@ module('Unit | Serializer | Allocation', function (hooks) {
name: 'abc',
state: 'running',
failed: false,
+ events: [],
},
{
name: 'xyz',
state: 'running',
failed: false,
+ events: [],
},
],
wasPreempted: false,