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}} + - - -{{#if (not (eq @context "child"))}} {{#if this.system.shouldShowNamespaces}} - - {{this.job.namespace.name}} - + <@tableBody.Td data-test-job-namespace>{{@job.namespace.id}} {{/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 data-test-job-type={{@job.type}}> + {{@job.type}} + + + {{#if this.system.shouldShowNodepools}} + <@tableBody.Td data-test-job-node-pool>{{@job.nodePool}} + {{/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}} +
+ + 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 this.visibleJobs.length}} - - {{/if}} -
- {{#if (media "isMobile")}} -
- {{#if (can "run job" namespace=this.qpNamespace)}} - - Run Job - - {{else}} - - {{/if}} -
- {{/if}} -
-
- {{#if this.system.shouldShowNamespaces}} - + + + + + + + {{#each this.filterFacets as |group|}} + + + {{#each group.options as |option|}} + + {{option.key}} + + {{else}} + + No {{group.label}} filters + + {{/each}} + + {{/each}} + + {{#if this.system.shouldShowNamespaces}} + + + {{#each this.namespaceFacet.options as |option|}} + + {{option.label}} + + {{/each}} + + {{/if}} + + {{#if this.filter}} + + {{/if}} + + + + {{#if this.pendingJobIDDiff}} + {{/if}} - - - - - -
-
- {{#if (not (media "isMobile"))}} -
- {{#if (can "run job" namespace=this.qpNamespace)}} - + +
+ + + + + {{#if this.isForbidden}} + + {{else if this.jobs.length}} + + <:body as |B|> + + + + +
+ +
+ +
+
+ {{else}} + + {{#if this.filter}} + + + + + + {{!-- TODO: HDS4.0, convert to F.LinkStandalone --}} + + + {{else}} + + + + {{!-- TODO: HDS4.0, convert to F.LinkStandalone --}} + + {{/if}} -
+ {{/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,