From 92391683da29a6f11c2b375c0b9880a27acea8fa Mon Sep 17 00:00:00 2001 From: Michael Lange Date: Thu, 29 Apr 2021 13:00:59 -0700 Subject: [PATCH] ui: Update namespaces design (#10444) This rethinks namespaces as a filter on list pages rather than a global setting. The biggest net-new feature here is being able to select All (*) to list all jobs or CSI volumes across namespaces. --- ui/app/abilities/abstract.js | 33 +-- ui/app/abilities/allocation.js | 4 +- ui/app/abilities/job.js | 8 +- ui/app/adapters/namespace.js | 4 +- ui/app/adapters/watchable-namespace-ids.js | 18 +- ui/app/adapters/watchable.js | 5 +- ui/app/components/job-row.js | 1 + .../single-select-dropdown/index.hbs | 13 ++ .../single-select-dropdown/index.js | 13 ++ ui/app/controllers/csi/volumes.js | 8 - ui/app/controllers/csi/volumes/index.js | 60 ++++- ui/app/controllers/csi/volumes/volume.js | 7 + ui/app/controllers/jobs.js | 15 +- ui/app/controllers/jobs/index.js | 51 ++++- ui/app/controllers/jobs/job.js | 10 + ui/app/controllers/jobs/job/definition.js | 4 +- ui/app/controllers/jobs/run.js | 2 +- ui/app/controllers/optimize.js | 66 ++++-- ui/app/controllers/settings/tokens.js | 3 - ui/app/mixins/with-namespace-resetting.js | 1 - ui/app/routes/application.js | 10 +- ui/app/routes/csi/volumes.js | 29 +-- ui/app/routes/csi/volumes/index.js | 38 +++- ui/app/routes/csi/volumes/volume.js | 10 +- ui/app/routes/exec.js | 2 +- ui/app/routes/jobs.js | 33 +-- ui/app/routes/jobs/index.js | 33 ++- ui/app/routes/jobs/job.js | 6 +- ui/app/routes/jobs/run.js | 6 +- ui/app/routes/optimize.js | 14 +- ui/app/routes/optimize/index.js | 4 +- ui/app/routes/optimize/summary.js | 2 +- ui/app/serializers/allocation.js | 6 +- ui/app/serializers/deployment.js | 6 +- ui/app/serializers/evaluation.js | 6 +- ui/app/services/system.js | 36 +-- ui/app/styles/components/dropdown.scss | 24 +- ui/app/styles/storybook.scss | 5 + ui/app/styles/utils/structure-colors.scss | 2 + .../templates/components/exec/open-button.hbs | 5 +- ui/app/templates/components/gutter-menu.hbs | 23 -- .../components/job-page/periodic-child.hbs | 2 +- ui/app/templates/components/job-row.hbs | 5 +- ui/app/templates/csi/volumes/index.hbs | 55 +++-- ui/app/templates/jobs/index.hbs | 152 +++++++------ ui/app/templates/optimize.hbs | 31 ++- ui/app/utils/qp-serialize.js | 5 +- ui/mirage/config.js | 43 ++-- ui/mirage/scenarios/default.js | 1 + .../components/filter-facets.stories.js | 207 ++++++++++++++++++ .../multi-select-dropdown.stories.js | 131 ----------- ui/tests/acceptance/job-detail-test.js | 23 -- ui/tests/acceptance/jobs-list-test.js | 147 ++++++++----- ui/tests/acceptance/namespaces-test.js | 174 --------------- ui/tests/acceptance/optimize-test.js | 124 +++++++---- ui/tests/acceptance/task-detail-test.js | 6 +- ui/tests/acceptance/token-test.js | 17 -- ui/tests/acceptance/volume-detail-test.js | 2 +- ui/tests/acceptance/volumes-list-test.js | 103 ++++++++- ui/tests/helpers/setup-ability.js | 2 + .../components/multi-select-dropdown-test.js | 8 +- .../components/single-select-dropdown-test.js | 80 +++++++ ui/tests/pages/clients/list.js | 10 +- ui/tests/pages/components/facet.js | 31 ++- ui/tests/pages/jobs/list.js | 23 +- ui/tests/pages/layout.js | 8 - ui/tests/pages/optimize.js | 14 +- .../storage/plugins/plugin/allocations.js | 6 +- ui/tests/pages/storage/volumes/list.js | 13 +- ui/tests/unit/abilities/allocation-test.js | 52 ++--- ui/tests/unit/abilities/job-test.js | 59 ++--- ui/tests/unit/adapters/volume-test.js | 14 -- 72 files changed, 1179 insertions(+), 995 deletions(-) create mode 100644 ui/app/components/single-select-dropdown/index.hbs create mode 100644 ui/app/components/single-select-dropdown/index.js create mode 100644 ui/app/controllers/jobs/job.js create mode 100644 ui/stories/components/filter-facets.stories.js delete mode 100644 ui/stories/components/multi-select-dropdown.stories.js delete mode 100644 ui/tests/acceptance/namespaces-test.js create mode 100644 ui/tests/integration/components/single-select-dropdown-test.js diff --git a/ui/app/abilities/abstract.js b/ui/app/abilities/abstract.js index 2c0e810c6..9dcf5c717 100644 --- a/ui/app/abilities/abstract.js +++ b/ui/app/abilities/abstract.js @@ -12,19 +12,24 @@ export default class Abstract extends Ability { @not('token.aclEnabled') bypassAuthorization; @equal('token.selfToken.type', 'management') selfTokenIsManagement; - @computed('system.activeNamespace.name') - get activeNamespace() { - return this.get('system.activeNamespace.name') || 'default'; + // Pass in a namespace to `can` or `cannot` calls to override + // https://github.com/minutebase/ember-can#additional-attributes + namespace = 'default'; + + get _namespace() { + if (!this.namespace) return 'default'; + if (typeof this.namespace === 'string') return this.namespace; + return get(this.namespace, 'name'); } - @computed('activeNamespace', 'token.selfTokenPolicies.[]') - get rulesForActiveNamespace() { - let activeNamespace = this.activeNamespace; + @computed('_namespace', 'token.selfTokenPolicies.[]') + get rulesForNamespace() { + let namespace = this._namespace; return (this.get('token.selfTokenPolicies') || []).toArray().reduce((rules, policy) => { let policyNamespaces = get(policy, 'rulesJSON.Namespaces') || []; - let matchingNamespace = this._findMatchingNamespace(policyNamespaces, activeNamespace); + let matchingNamespace = this._findMatchingNamespace(policyNamespaces, namespace); if (matchingNamespace) { rules.push(policyNamespaces.find(namespace => namespace.Name === matchingNamespace)); @@ -46,8 +51,8 @@ export default class Abstract extends Ability { }, []); } - activeNamespaceIncludesCapability(capability) { - return this.rulesForActiveNamespace.some(rules => { + namespaceIncludesCapability(capability) { + return this.rulesForNamespace.some(rules => { let capabilities = get(rules, 'Capabilities') || []; return capabilities.includes(capability); }); @@ -64,11 +69,11 @@ export default class Abstract extends Ability { // Chooses the closest namespace as described at the bottom here: // https://learn.hashicorp.com/tutorials/nomad/access-control-policies?in=nomad/access-control#namespace-rules - _findMatchingNamespace(policyNamespaces, activeNamespace) { + _findMatchingNamespace(policyNamespaces, namespace) { let namespaceNames = policyNamespaces.mapBy('Name'); - if (namespaceNames.includes(activeNamespace)) { - return activeNamespace; + if (namespaceNames.includes(namespace)) { + return namespace; } let globNamespaceNames = namespaceNames.filter(namespaceName => namespaceName.includes('*')); @@ -77,11 +82,11 @@ export default class Abstract extends Ability { (mostMatching, namespaceName) => { // Convert * wildcards to .* for regex matching let namespaceNameRegExp = new RegExp(namespaceName.replace(/\*/g, '.*')); - let characterDifference = activeNamespace.length - namespaceName.length; + let characterDifference = namespace.length - namespaceName.length; if ( characterDifference < mostMatching.mostMatchingCharacterDifference && - activeNamespace.match(namespaceNameRegExp) + namespace.match(namespaceNameRegExp) ) { return { mostMatchingNamespaceName: namespaceName, diff --git a/ui/app/abilities/allocation.js b/ui/app/abilities/allocation.js index 0ac627049..7f288b03c 100644 --- a/ui/app/abilities/allocation.js +++ b/ui/app/abilities/allocation.js @@ -6,9 +6,9 @@ export default class Allocation extends AbstractAbility { @or('bypassAuthorization', 'selfTokenIsManagement', 'policiesSupportExec') canExec; - @computed('rulesForActiveNamespace.@each.capabilities') + @computed('rulesForNamespace.@each.capabilities') get policiesSupportExec() { - return this.rulesForActiveNamespace.some(rules => { + return this.rulesForNamespace.some(rules => { let capabilities = get(rules, 'Capabilities') || []; return capabilities.includes('alloc-exec'); }); diff --git a/ui/app/abilities/job.js b/ui/app/abilities/job.js index 8b2d8fcf8..3c7465b59 100644 --- a/ui/app/abilities/job.js +++ b/ui/app/abilities/job.js @@ -20,13 +20,13 @@ export default class Job extends AbstractAbility { @or('bypassAuthorization', 'selfTokenIsManagement') canListAll; - @computed('rulesForActiveNamespace.@each.capabilities') + @computed('rulesForNamespace.@each.capabilities') get policiesSupportRunning() { - return this.activeNamespaceIncludesCapability('submit-job'); + return this.namespaceIncludesCapability('submit-job'); } - @computed('rulesForActiveNamespace.@each.capabilities') + @computed('rulesForNamespace.@each.capabilities') get policiesSupportScaling() { - return this.activeNamespaceIncludesCapability('scale-job'); + return this.namespaceIncludesCapability('scale-job'); } } diff --git a/ui/app/adapters/namespace.js b/ui/app/adapters/namespace.js index 365c0933d..9594862d2 100644 --- a/ui/app/adapters/namespace.js +++ b/ui/app/adapters/namespace.js @@ -1,7 +1,7 @@ -import ApplicationAdapter from './application'; +import Watchable from './watchable'; import codesForError from '../utils/codes-for-error'; -export default class NamespaceAdapter extends ApplicationAdapter { +export default class NamespaceAdapter extends Watchable { findRecord(store, modelClass, id) { return super.findRecord(...arguments).catch(error => { const errorCodes = codesForError(error); diff --git a/ui/app/adapters/watchable-namespace-ids.js b/ui/app/adapters/watchable-namespace-ids.js index 5ca12a368..11219deb9 100644 --- a/ui/app/adapters/watchable-namespace-ids.js +++ b/ui/app/adapters/watchable-namespace-ids.js @@ -7,10 +7,18 @@ export default class WatchableNamespaceIDs extends Watchable { @service system; findAll() { - const namespace = this.get('system.activeNamespace'); return super.findAll(...arguments).then(data => { data.forEach(record => { - record.Namespace = namespace ? namespace.get('id') : 'default'; + record.Namespace = 'default'; + }); + return data; + }); + } + + query(store, type, { namespace }) { + return super.query(...arguments).then(data => { + data.forEach(record => { + if (!record.Namespace) record.Namespace = namespace; }); return data; }); @@ -25,14 +33,12 @@ export default class WatchableNamespaceIDs extends Watchable { urlForFindAll() { const url = super.urlForFindAll(...arguments); - const namespace = this.get('system.activeNamespace.id'); - return associateNamespace(url, namespace); + return associateNamespace(url); } urlForQuery() { const url = super.urlForQuery(...arguments); - const namespace = this.get('system.activeNamespace.id'); - return associateNamespace(url, namespace); + return associateNamespace(url); } urlForFindRecord(id, type, hash, pathSuffix) { diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js index a1e4c6a04..456f2a2bc 100644 --- a/ui/app/adapters/watchable.js +++ b/ui/app/adapters/watchable.js @@ -48,11 +48,12 @@ export default class Watchable extends ApplicationAdapter { } findRecord(store, type, id, snapshot, additionalParams = {}) { - let [url, params] = this.buildURL(type.modelName, id, snapshot, 'findRecord').split('?'); + const originalUrl = this.buildURL(type.modelName, id, snapshot, 'findRecord'); + let [url, params] = originalUrl.split('?'); params = assign(queryString.parse(params) || {}, this.buildQuery(), additionalParams); if (get(snapshot || {}, 'adapterOptions.watch')) { - params.index = this.watchList.getIndexFor(url); + params.index = this.watchList.getIndexFor(originalUrl); } const signal = get(snapshot || {}, 'adapterOptions.abortController.signal'); diff --git a/ui/app/components/job-row.js b/ui/app/components/job-row.js index 0ac60b158..7413827b0 100644 --- a/ui/app/components/job-row.js +++ b/ui/app/components/job-row.js @@ -8,6 +8,7 @@ import classic from 'ember-classic-decorator'; @tagName('tr') @classNames('job-row', 'is-interactive') export default class JobRow extends Component { + @service system; @service store; job = null; diff --git a/ui/app/components/single-select-dropdown/index.hbs b/ui/app/components/single-select-dropdown/index.hbs new file mode 100644 index 000000000..8bb4911ad --- /dev/null +++ b/ui/app/components/single-select-dropdown/index.hbs @@ -0,0 +1,13 @@ + diff --git a/ui/app/components/single-select-dropdown/index.js b/ui/app/components/single-select-dropdown/index.js new file mode 100644 index 000000000..205610e0b --- /dev/null +++ b/ui/app/components/single-select-dropdown/index.js @@ -0,0 +1,13 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class SingleSelectDropdown extends Component { + get activeOption() { + return this.args.options.findBy('key', this.args.selection); + } + + @action + setSelection({ key }) { + this.args.onSelect && this.args.onSelect(key); + } +} diff --git a/ui/app/controllers/csi/volumes.js b/ui/app/controllers/csi/volumes.js index 94772e3bc..1eb7ecfec 100644 --- a/ui/app/controllers/csi/volumes.js +++ b/ui/app/controllers/csi/volumes.js @@ -1,13 +1,5 @@ import Controller from '@ember/controller'; export default class VolumesController extends Controller { - queryParams = [ - { - volumeNamespace: 'namespace', - }, - ]; - isForbidden = false; - - volumeNamespace = 'default'; } diff --git a/ui/app/controllers/csi/volumes/index.js b/ui/app/controllers/csi/volumes/index.js index 3ff9cfb3a..da67964c9 100644 --- a/ui/app/controllers/csi/volumes/index.js +++ b/ui/app/controllers/csi/volumes/index.js @@ -1,10 +1,12 @@ import { inject as service } from '@ember/service'; import { action, computed } from '@ember/object'; import { alias, readOnly } from '@ember/object/computed'; +import { scheduleOnce } from '@ember/runloop'; import Controller, { inject as controller } from '@ember/controller'; import SortableFactory from 'nomad-ui/mixins/sortable-factory'; import Searchable from 'nomad-ui/mixins/searchable'; import { lazyClick } from 'nomad-ui/helpers/lazy-click'; +import { serialize } from 'nomad-ui/utils/qp-serialize'; import classic from 'ember-classic-decorator'; @classic @@ -38,6 +40,9 @@ export default class IndexController extends Controller.extend( { sortDescending: 'desc', }, + { + qpNamespace: 'namespace', + }, ]; currentPage = 1; @@ -58,29 +63,60 @@ export default class IndexController extends Controller.extend( fuzzySearchEnabled = true; + @computed('qpNamespace', 'model.namespaces.[]', 'system.cachedNamespace') + get optionsNamespaces() { + const availableNamespaces = this.model.namespaces.map(namespace => ({ + key: namespace.name, + label: namespace.name, + })); + + availableNamespaces.unshift({ + key: '*', + label: 'All (*)', + }); + + // Unset the namespace selection if it was server-side deleted + if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) { + // eslint-disable-next-line ember/no-incorrect-calls-with-inline-anonymous-functions + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.set('qpNamespace', this.system.cachedNamespace || 'default'); + }); + } + + return availableNamespaces; + } + /** Visible volumes are those that match the selected namespace */ - @computed('model.@each.parent', 'system.{activeNamespace.id,namespaces.length}') + @computed('model.volumes.@each.parent', 'system.{namespaces.length}') get visibleVolumes() { - if (!this.model) return []; - - // Namespace related properties are ommitted from the dependent keys - // due to a prop invalidation bug caused by region switching. - const hasNamespaces = this.get('system.namespaces.length'); - const activeNamespace = this.get('system.activeNamespace.id') || 'default'; - - return this.model - .compact() - .filter(volume => !hasNamespaces || volume.get('namespace.id') === activeNamespace); + if (!this.model.volumes) return []; + return this.model.volumes.compact(); } @alias('visibleVolumes') listToSort; @alias('listSorted') listToSearch; @alias('listSearched') sortedVolumes; + @action + cacheNamespace(namespace) { + this.system.cachedNamespace = namespace; + } + + setFacetQueryParam(queryParam, selection) { + this.set(queryParam, serialize(selection)); + } + @action gotoVolume(volume, event) { - lazyClick([() => this.transitionToRoute('csi.volumes.volume', volume.get('plainId')), event]); + lazyClick([ + () => + this.transitionToRoute('csi.volumes.volume', volume.get('plainId'), { + queryParams: { volumeNamespace: volume.get('namespace.name') }, + }), + event, + ]); } } diff --git a/ui/app/controllers/csi/volumes/volume.js b/ui/app/controllers/csi/volumes/volume.js index d0f6ce12d..e687a078c 100644 --- a/ui/app/controllers/csi/volumes/volume.js +++ b/ui/app/controllers/csi/volumes/volume.js @@ -6,6 +6,13 @@ export default class VolumeController extends Controller { // Used in the template @service system; + queryParams = [ + { + volumeNamespace: 'namespace', + }, + ]; + volumeNamespace = 'default'; + @computed('model.readAllocations.@each.modifyIndex') get sortedReadAllocations() { return this.model.readAllocations.sortBy('modifyIndex').reverse(); diff --git a/ui/app/controllers/jobs.js b/ui/app/controllers/jobs.js index 53b270e7f..d6d5e36fd 100644 --- a/ui/app/controllers/jobs.js +++ b/ui/app/controllers/jobs.js @@ -1,16 +1,3 @@ -import { inject as service } from '@ember/service'; import Controller from '@ember/controller'; -export default class JobsController extends Controller { - @service system; - - queryParams = [ - { - jobNamespace: 'namespace', - }, - ]; - - isForbidden = false; - - jobNamespace = 'default'; -} +export default class JobsController extends Controller {} diff --git a/ui/app/controllers/jobs/index.js b/ui/app/controllers/jobs/index.js index 7c6add4ac..12c05fbd1 100644 --- a/ui/app/controllers/jobs/index.js +++ b/ui/app/controllers/jobs/index.js @@ -1,7 +1,7 @@ /* 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, { inject as controller } from '@ember/controller'; +import Controller from '@ember/controller'; import { action, computed } from '@ember/object'; import { scheduleOnce } from '@ember/runloop'; import intersection from 'lodash.intersection'; @@ -14,9 +14,8 @@ import classic from 'ember-classic-decorator'; export default class IndexController extends Controller.extend(Sortable, Searchable) { @service system; @service userSettings; - @controller('jobs') jobsController; - @alias('jobsController.isForbidden') isForbidden; + isForbidden = false; queryParams = [ { @@ -43,6 +42,9 @@ export default class IndexController extends Controller.extend(Sortable, Searcha { qpPrefix: 'prefix', }, + { + qpNamespace: 'namespace', + }, ]; currentPage = 1; @@ -150,21 +152,39 @@ export default class IndexController extends Controller.extend(Sortable, Searcha })); } + @computed('qpNamespace', 'model.namespaces.[]', 'system.cachedNamespace') + get optionsNamespaces() { + const availableNamespaces = this.model.namespaces.map(namespace => ({ + key: namespace.name, + label: namespace.name, + })); + + availableNamespaces.unshift({ + key: '*', + label: 'All (*)', + }); + + // Unset the namespace selection if it was server-side deleted + if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) { + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.set('qpNamespace', this.system.cachedNamespace || 'default'); + }); + } + + return availableNamespaces; + } + /** Visible jobs are those that match the selected namespace and aren't children of periodic or parameterized jobs. */ - @computed('model.@each.parent', 'system.{activeNamespace.id,namespaces.length}') + @computed('model.jobs.@each.parent') get visibleJobs() { - // Namespace related properties are ommitted from the dependent keys - // due to a prop invalidation bug caused by region switching. - const hasNamespaces = this.get('system.namespaces.length'); - const activeNamespace = this.get('system.activeNamespace.id') || 'default'; - - return this.model + if (!this.model || !this.model.jobs) return []; + return this.model.jobs .compact() .filter(job => !job.isNew) - .filter(job => !hasNamespaces || job.get('namespace.id') === activeNamespace) .filter(job => !job.get('parent.content')); } @@ -213,12 +233,19 @@ export default class IndexController extends Controller.extend(Sortable, Searcha isShowingDeploymentDetails = false; + @action + cacheNamespace(namespace) { + this.system.cachedNamespace = namespace; + } + setFacetQueryParam(queryParam, selection) { this.set(queryParam, serialize(selection)); } @action gotoJob(job) { - this.transitionToRoute('jobs.job', job.get('plainId')); + this.transitionToRoute('jobs.job', job.get('plainId'), { + queryParams: { namespace: job.get('namespace.name') }, + }); } } diff --git a/ui/app/controllers/jobs/job.js b/ui/app/controllers/jobs/job.js new file mode 100644 index 000000000..519d9eae3 --- /dev/null +++ b/ui/app/controllers/jobs/job.js @@ -0,0 +1,10 @@ +import Controller from '@ember/controller'; + +export default class JobController extends Controller { + queryParams = [ + { + jobNamespace: 'namespace', + }, + ]; + jobNamespace = 'default'; +} diff --git a/ui/app/controllers/jobs/job/definition.js b/ui/app/controllers/jobs/job/definition.js index 16c3390f5..faa94d92f 100644 --- a/ui/app/controllers/jobs/job/definition.js +++ b/ui/app/controllers/jobs/job/definition.js @@ -19,9 +19,9 @@ export default class DefinitionController extends Controller.extend(WithNamespac this.set('isEditing', false); } - onSubmit(id, namespace) { + onSubmit(id, jobNamespace) { this.transitionToRoute('jobs.job', id, { - queryParams: { jobNamespace: namespace }, + queryParams: { jobNamespace }, }); } } diff --git a/ui/app/controllers/jobs/run.js b/ui/app/controllers/jobs/run.js index f57d43023..592171301 100644 --- a/ui/app/controllers/jobs/run.js +++ b/ui/app/controllers/jobs/run.js @@ -3,7 +3,7 @@ import Controller from '@ember/controller'; export default class RunController extends Controller { onSubmit(id, namespace) { this.transitionToRoute('jobs.job', id, { - queryParams: { jobNamespace: namespace }, + queryParams: { namespace }, }); } } diff --git a/ui/app/controllers/optimize.js b/ui/app/controllers/optimize.js index 9e226a582..eed3f33ea 100644 --- a/ui/app/controllers/optimize.js +++ b/ui/app/controllers/optimize.js @@ -21,10 +21,10 @@ export default class OptimizeController extends Controller { queryParams = [ { - includeAllNamespaces: 'all-namespaces', + searchTerm: 'search', }, { - searchTerm: 'search', + qpNamespace: 'namespacefilter', }, { qpType: 'type', @@ -48,21 +48,53 @@ export default class OptimizeController extends Controller { }); } + get namespaces() { + return this.model.namespaces; + } + + get summaries() { + return this.model.summaries; + } + @tracked searchTerm = ''; @tracked qpType = ''; @tracked qpStatus = ''; @tracked qpDatacenter = ''; @tracked qpPrefix = ''; - - @tracked includeAllNamespaces = true; + @tracked qpNamespace = '*'; @selection('qpType') selectionType; @selection('qpStatus') selectionStatus; @selection('qpDatacenter') selectionDatacenter; @selection('qpPrefix') selectionPrefix; - optionsType = [{ key: 'service', label: 'Service' }, { key: 'system', label: 'System' }]; + get optionsNamespaces() { + const availableNamespaces = this.namespaces.map(namespace => ({ + key: namespace.name, + label: namespace.name, + })); + + availableNamespaces.unshift({ + key: '*', + label: 'All (*)', + }); + + // Unset the namespace selection if it was server-side deleted + if (!availableNamespaces.mapBy('key').includes(this.qpNamespace)) { + scheduleOnce('actions', () => { + // eslint-disable-next-line ember/no-side-effects + this.qpNamespace = this.system.cachedNamespace || 'default'; + }); + } + + return availableNamespaces; + } + + optionsType = [ + { key: 'service', label: 'Service' }, + { key: 'system', label: 'System' }, + ]; optionsStatus = [ { key: 'pending', label: 'Pending' }, @@ -72,7 +104,7 @@ export default class OptimizeController extends Controller { get optionsDatacenter() { const flatten = (acc, val) => acc.concat(val); - const allDatacenters = new Set(this.model.mapBy('job.datacenters').reduce(flatten, [])); + const allDatacenters = new Set(this.summaries.mapBy('job.datacenters').reduce(flatten, [])); // Remove any invalid datacenters from the query param/selection const availableDatacenters = Array.from(allDatacenters).compact(); @@ -90,7 +122,7 @@ export default class OptimizeController extends Controller { const hasPrefix = /.[-._]/; // Collect and count all the prefixes - const allNames = this.model.mapBy('job.name'); + const allNames = this.summaries.mapBy('job.name'); const nameHistogram = allNames.reduce((hist, name) => { if (hasPrefix.test(name)) { const prefix = name.match(/(.+?)[-._]/)[1]; @@ -130,9 +162,6 @@ export default class OptimizeController extends Controller { selectionPrefix: prefixes, } = this; - const shouldShowNamespaces = this.system.shouldShowNamespaces; - const activeNamespace = shouldShowNamespaces ? this.system.activeNamespace.name : undefined; - // A summary’s 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.summarySearch.listSearched.filter(summary => { @@ -142,11 +171,7 @@ export default class OptimizeController extends Controller { return false; } - if ( - shouldShowNamespaces && - !this.includeAllNamespaces && - activeNamespace !== summary.jobNamespace - ) { + if (this.qpNamespace !== '*' && job.get('namespace.name') !== this.qpNamespace) { return false; } @@ -201,14 +226,13 @@ export default class OptimizeController extends Controller { } @action - setFacetQueryParam(queryParam, selection) { - this[queryParam] = serialize(selection); - this.syncActiveSummary(); + cacheNamespace(namespace) { + this.system.cachedNamespace = namespace; } @action - toggleIncludeAllNamespaces() { - this.includeAllNamespaces = !this.includeAllNamespaces; + setFacetQueryParam(queryParam, selection) { + this[queryParam] = serialize(selection); this.syncActiveSummary(); } @@ -238,7 +262,7 @@ class RecommendationSummarySearch extends EmberObject.extend(Searchable) { return ['slug']; } - @alias('dataSource.model') listToSearch; + @alias('dataSource.summaries') listToSearch; @alias('dataSource.searchTerm') searchTerm; exactMatchEnabled = false; diff --git a/ui/app/controllers/settings/tokens.js b/ui/app/controllers/settings/tokens.js index 541431fb4..3ac048863 100644 --- a/ui/app/controllers/settings/tokens.js +++ b/ui/app/controllers/settings/tokens.js @@ -9,7 +9,6 @@ import classic from 'ember-classic-decorator'; @classic export default class Tokens extends Controller { @service token; - @service system; @service store; @reads('token.secret') secret; @@ -32,7 +31,6 @@ export default class Tokens extends Controller { tokenIsInvalid: false, }); // Clear out all data to ensure only data the anonymous token is privileged to see is shown - this.system.reset(); this.resetStore(); this.token.reset(); } @@ -47,7 +45,6 @@ export default class Tokens extends Controller { TokenAdapter.findSelf().then( () => { // Clear out all data to ensure only data the new token is privileged to see is shown - this.system.reset(); this.resetStore(); // Refetch the token and associated policies diff --git a/ui/app/mixins/with-namespace-resetting.js b/ui/app/mixins/with-namespace-resetting.js index 04469ef1b..514c7b396 100644 --- a/ui/app/mixins/with-namespace-resetting.js +++ b/ui/app/mixins/with-namespace-resetting.js @@ -12,7 +12,6 @@ export default Mixin.create({ // Since the setupController hook doesn't fire when transitioning up the // route hierarchy, the two sides of the namespace bindings need to be manipulated // in order for the jobs route model to reload. - this.set('system.activeNamespace', this.get('jobsController.jobNamespace')); this.set('jobsController.jobNamespace', namespace.get('id')); this.transitionToRoute('jobs'); }, diff --git a/ui/app/routes/application.js b/ui/app/routes/application.js index e0162369e..f92fd5b3c 100644 --- a/ui/app/routes/application.js +++ b/ui/app/routes/application.js @@ -80,7 +80,6 @@ export default class ApplicationRoute extends Route { (queryParam && queryParam !== currentRegion) || (!queryParam && currentRegion !== defaultRegion) ) { - this.system.reset(); this.store.unloadAll(); } @@ -91,7 +90,14 @@ export default class ApplicationRoute extends Route { // Model is being used as a way to propagate the region and // one time token query parameters for use in setupController. - model({ region }, { to: { queryParams: { ott }}}) { + model( + { region }, + { + to: { + queryParams: { ott }, + }, + } + ) { return { region, hasOneTimeToken: ott, diff --git a/ui/app/routes/csi/volumes.js b/ui/app/routes/csi/volumes.js index 14d0ada01..e81232393 100644 --- a/ui/app/routes/csi/volumes.js +++ b/ui/app/routes/csi/volumes.js @@ -1,11 +1,9 @@ import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; -import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; -import notifyForbidden from 'nomad-ui/utils/notify-forbidden'; import classic from 'ember-classic-decorator'; @classic -export default class VolumesRoute extends Route.extend(WithForbiddenState) { +export default class VolumesRoute extends Route.extend() { @service system; @service store; @@ -15,29 +13,4 @@ export default class VolumesRoute extends Route.extend(WithForbiddenState) { args: ['csi.index'], }, ]; - - queryParams = { - volumeNamespace: { - refreshModel: true, - }, - }; - - beforeModel(transition) { - return this.get('system.namespaces').then(namespaces => { - const queryParam = transition.to.queryParams.namespace; - this.set('system.activeNamespace', queryParam || 'default'); - - return namespaces; - }); - } - - model() { - return this.store - .query('volume', { type: 'csi' }) - .then(volumes => { - volumes.forEach(volume => volume.plugin); - return volumes; - }) - .catch(notifyForbidden(this)); - } } diff --git a/ui/app/routes/csi/volumes/index.js b/ui/app/routes/csi/volumes/index.js index e405bb5bd..f2db7cfa0 100644 --- a/ui/app/routes/csi/volumes/index.js +++ b/ui/app/routes/csi/volumes/index.js @@ -1,13 +1,39 @@ +import { inject as service } from '@ember/service'; +import RSVP from 'rsvp'; import Route from '@ember/routing/route'; import { collect } from '@ember/object/computed'; -import { watchQuery } from 'nomad-ui/utils/properties/watch'; +import { watchQuery, 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'; -export default class IndexRoute extends Route.extend(WithWatchers) { - startWatchers(controller) { - controller.set('modelWatch', this.watch.perform({ type: 'csi' })); +export default class IndexRoute extends Route.extend(WithWatchers, WithForbiddenState) { + @service store; + + queryParams = { + qpNamespace: { + refreshModel: true, + }, + }; + + model(params) { + return RSVP.hash({ + volumes: this.store + .query('volume', { type: 'csi', namespace: params.qpNamespace }) + .catch(notifyForbidden(this)), + namespaces: this.store.findAll('namespace'), + }); } - @watchQuery('volume') watch; - @collect('watch') watchers; + startWatchers(controller) { + controller.set('namespacesWatch', this.watchNamespaces.perform()); + controller.set( + 'modelWatch', + this.watchVolumes.perform({ type: 'csi', namespace: controller.qpNamespace }) + ); + } + + @watchQuery('volume') watchVolumes; + @watchAll('namespace') watchNamespaces; + @collect('watchVolumes', 'watchNamespaces') watchers; } diff --git a/ui/app/routes/csi/volumes/volume.js b/ui/app/routes/csi/volumes/volume.js index 62523ff76..7a68264e6 100644 --- a/ui/app/routes/csi/volumes/volume.js +++ b/ui/app/routes/csi/volumes/volume.js @@ -1,6 +1,7 @@ import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; import { collect } from '@ember/object/computed'; +import RSVP from 'rsvp'; import notifyError from 'nomad-ui/utils/notify-error'; import { qpBuilder } from 'nomad-ui/utils/classes/query-params'; import { watchRecord } from 'nomad-ui/utils/properties/watch'; @@ -43,10 +44,15 @@ export default class VolumeRoute extends Route.extend(WithWatchers) { } model(params, transition) { - const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id'); + const namespace = transition.to.queryParams.namespace; const name = params.volume_name; const fullId = JSON.stringify([`csi/${name}`, namespace || 'default']); - return this.store.findRecord('volume', fullId, { reload: true }).catch(notifyError(this)); + return RSVP.hash({ + volume: this.store.findRecord('volume', fullId, { reload: true }), + namespaces: this.store.findAll('namespace'), + }) + .then(hash => hash.volume) + .catch(notifyError(this)); } // Since volume includes embedded records for allocations, diff --git a/ui/app/routes/exec.js b/ui/app/routes/exec.js index 4ad7fbea9..4a9d009bd 100644 --- a/ui/app/routes/exec.js +++ b/ui/app/routes/exec.js @@ -16,7 +16,7 @@ export default class ExecRoute extends Route.extend(WithWatchers) { } model(params, transition) { - const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id'); + const namespace = transition.to.queryParams.namespace; const name = params.job_name; const fullId = JSON.stringify([name, namespace || 'default']); diff --git a/ui/app/routes/jobs.js b/ui/app/routes/jobs.js index 8ba64a9e2..5599f63ab 100644 --- a/ui/app/routes/jobs.js +++ b/ui/app/routes/jobs.js @@ -1,43 +1,12 @@ -import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; -import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state'; -import notifyForbidden from 'nomad-ui/utils/notify-forbidden'; -import { action } from '@ember/object'; import classic from 'ember-classic-decorator'; @classic -export default class JobsRoute extends Route.extend(WithForbiddenState) { - @service store; - @service system; - +export default class JobsRoute extends Route.extend() { breadcrumbs = [ { label: 'Jobs', args: ['jobs.index'], }, ]; - - queryParams = { - jobNamespace: { - refreshModel: true, - }, - }; - - beforeModel(transition) { - return this.get('system.namespaces').then(namespaces => { - const queryParam = transition.to.queryParams.namespace; - this.set('system.activeNamespace', queryParam || 'default'); - - return namespaces; - }); - } - - model() { - return this.store.findAll('job', { reload: true }).catch(notifyForbidden(this)); - } - - @action - refreshRoute() { - this.refresh(); - } } diff --git a/ui/app/routes/jobs/index.js b/ui/app/routes/jobs/index.js index c8369e806..a9d4edf39 100644 --- a/ui/app/routes/jobs/index.js +++ b/ui/app/routes/jobs/index.js @@ -1,13 +1,34 @@ +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 } from 'nomad-ui/utils/properties/watch'; +import { watchAll, watchQuery } 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'; -export default class IndexRoute extends Route.extend(WithWatchers) { - startWatchers(controller) { - controller.set('modelWatch', this.watch.perform()); +export default class IndexRoute extends Route.extend(WithWatchers, WithForbiddenState) { + @service store; + + queryParams = { + qpNamespace: { + refreshModel: true, + }, + }; + + model(params) { + return RSVP.hash({ + jobs: this.store.query('job', { namespace: params.qpNamespace }).catch(notifyForbidden(this)), + namespaces: this.store.findAll('namespace'), + }); } - @watchAll('job') watch; - @collect('watch') watchers; + startWatchers(controller) { + controller.set('namespacesWatch', this.watchNamespaces.perform()); + controller.set('modelWatch', this.watchJobs.perform({ namespace: controller.qpNamesapce })); + } + + @watchQuery('job') watchJobs; + @watchAll('namespace') watchNamespaces; + @collect('watchJobs', 'watchNamespaces') watchers; } diff --git a/ui/app/routes/jobs/job.js b/ui/app/routes/jobs/job.js index 61daf1248..32709b0be 100644 --- a/ui/app/routes/jobs/job.js +++ b/ui/app/routes/jobs/job.js @@ -18,9 +18,9 @@ export default class JobRoute extends Route { } model(params, transition) { - const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id'); + const namespace = transition.to.queryParams.namespace || 'default'; const name = params.job_name; - const fullId = JSON.stringify([name, namespace || 'default']); + const fullId = JSON.stringify([name, namespace]); return this.store .findRecord('job', fullId, { reload: true }) @@ -28,6 +28,8 @@ export default class JobRoute extends Route { const relatedModelsQueries = [ job.get('allocations'), job.get('evaluations'), + this.store.query('job', { namespace }), + this.store.findAll('namespace'), ]; if (this.can.can('accept recommendation')) { diff --git a/ui/app/routes/jobs/run.js b/ui/app/routes/jobs/run.js index c7a9de74f..ee72d95fb 100644 --- a/ui/app/routes/jobs/run.js +++ b/ui/app/routes/jobs/run.js @@ -22,8 +22,10 @@ export default class RunRoute extends Route { } model() { - return this.store.createRecord('job', { - namespace: this.get('system.activeNamespace'), + // When jobs are created with a namespace attribute, it is verified against + // available namespaces to prevent redirecting to a non-existent namespace. + return this.store.findAll('namespace').then(() => { + return this.store.createRecord('job'); }); } diff --git a/ui/app/routes/optimize.js b/ui/app/routes/optimize.js index 8a53f8892..b326b2b1e 100644 --- a/ui/app/routes/optimize.js +++ b/ui/app/routes/optimize.js @@ -25,14 +25,18 @@ export default class OptimizeRoute extends Route { async model() { const summaries = await this.store.findAll('recommendation-summary'); const jobs = await RSVP.all(summaries.mapBy('job')); - await RSVP.all( - jobs + const [namespaces] = await RSVP.all([ + this.store.findAll('namespace'), + ...jobs .filter(job => job) .filterBy('isPartial') - .map(j => j.reload()) - ); + .map(j => j.reload()), + ]); - return summaries.sortBy('submitTime').reverse(); + return { + summaries: summaries.sortBy('submitTime').reverse(), + namespaces, + }; } @action diff --git a/ui/app/routes/optimize/index.js b/ui/app/routes/optimize/index.js index baa207240..605a6479e 100644 --- a/ui/app/routes/optimize/index.js +++ b/ui/app/routes/optimize/index.js @@ -9,7 +9,9 @@ export default class OptimizeIndexRoute extends Route { const firstSummary = summaries.objectAt(0); return this.transitionTo('optimize.summary', firstSummary.slug, { - queryParams: { jobNamespace: firstSummary.jobNamespace || 'default' }, + queryParams: { + jobNamespace: firstSummary.jobNamespace || 'default', + }, }); } } diff --git a/ui/app/routes/optimize/summary.js b/ui/app/routes/optimize/summary.js index 19a64f640..09bcce488 100644 --- a/ui/app/routes/optimize/summary.js +++ b/ui/app/routes/optimize/summary.js @@ -14,7 +14,7 @@ export default class OptimizeSummaryRoute extends Route { } async model({ jobNamespace, slug }) { - const model = this.modelFor('optimize').find( + const model = this.modelFor('optimize').summaries.find( summary => summary.slug === slug && summary.jobNamespace === jobNamespace ); diff --git a/ui/app/serializers/allocation.js b/ui/app/serializers/allocation.js index 1946bb981..46922c357 100644 --- a/ui/app/serializers/allocation.js +++ b/ui/app/serializers/allocation.js @@ -52,11 +52,7 @@ export default class AllocationSerializer extends ApplicationSerializer { hash.JobVersion = hash.JobVersion != null ? hash.JobVersion : get(hash, 'Job.Version'); hash.PlainJobId = hash.JobID; - hash.Namespace = - hash.Namespace || - get(hash, 'Job.Namespace') || - this.get('system.activeNamespace.id') || - 'default'; + hash.Namespace = hash.Namespace || get(hash, 'Job.Namespace') || 'default'; hash.JobID = JSON.stringify([hash.JobID, hash.Namespace]); hash.RescheduleEvents = (hash.RescheduleTracker || {}).Events; diff --git a/ui/app/serializers/deployment.js b/ui/app/serializers/deployment.js index b6dbd0c8f..49e8ddcdb 100644 --- a/ui/app/serializers/deployment.js +++ b/ui/app/serializers/deployment.js @@ -14,11 +14,7 @@ export default class DeploymentSerializer extends ApplicationSerializer { normalize(typeHash, hash) { if (hash) { hash.PlainJobId = hash.JobID; - hash.Namespace = - hash.Namespace || - get(hash, 'Job.Namespace') || - this.get('system.activeNamespace.id') || - 'default'; + hash.Namespace = hash.Namespace || get(hash, 'Job.Namespace') || 'default'; // Ember Data doesn't support multiple inverses. This means that since jobs have // two relationships to a deployment (hasMany deployments, and belongsTo latestDeployment), diff --git a/ui/app/serializers/evaluation.js b/ui/app/serializers/evaluation.js index 6b291ab92..069539b12 100644 --- a/ui/app/serializers/evaluation.js +++ b/ui/app/serializers/evaluation.js @@ -12,11 +12,7 @@ export default class Evaluation extends ApplicationSerializer { normalize(typeHash, hash) { hash.PlainJobId = hash.JobID; - hash.Namespace = - hash.Namespace || - get(hash, 'Job.Namespace') || - this.get('system.activeNamespace.id') || - 'default'; + hash.Namespace = hash.Namespace || get(hash, 'Job.Namespace') || 'default'; hash.JobID = JSON.stringify([hash.JobID, hash.Namespace]); return super.normalize(typeHash, hash); diff --git a/ui/app/services/system.js b/ui/app/services/system.js index 106987c6a..8af8aa33a 100644 --- a/ui/app/services/system.js +++ b/ui/app/services/system.js @@ -1,5 +1,6 @@ import Service, { inject as service } from '@ember/service'; import { computed } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import { alias } from '@ember/object/computed'; import PromiseObject from '../utils/classes/promise-object'; import PromiseArray from '../utils/classes/promise-array'; @@ -113,36 +114,11 @@ export default class SystemService extends Service { return namespaces.length && namespaces.some(namespace => namespace.get('id') !== 'default'); } - @computed('namespaces.[]') - get activeNamespace() { - const namespaceId = window.localStorage.nomadActiveNamespace || 'default'; - const namespace = this.namespaces.findBy('id', namespaceId); - - if (namespace) { - return namespace; - } - - // If the namespace in localStorage is no longer in the cluster, it needs to - // be cleared from localStorage - window.localStorage.removeItem('nomadActiveNamespace'); - return this.namespaces.findBy('id', 'default'); - } - - set activeNamespace(value) { - if (value == null) { - window.localStorage.removeItem('nomadActiveNamespace'); - return; - } else if (typeof value === 'string') { - window.localStorage.nomadActiveNamespace = value; - } else { - window.localStorage.nomadActiveNamespace = value.get('name'); - } - } - - reset() { - this.set('activeNamespace', null); - this.notifyPropertyChange('namespaces'); - } + // The cachedNamespace is set on pages that have a namespaces filter. + // It is set so other pages that have a namespaces filter can default to + // what the previous namespaces filter page used rather than defaulting + // to 'default' or '*'. + @tracked cachedNamespace = null; @task(function*() { const emptyLicense = { License: { Features: [] } }; diff --git a/ui/app/styles/components/dropdown.scss b/ui/app/styles/components/dropdown.scss index 550c3582f..3f82ad415 100644 --- a/ui/app/styles/components/dropdown.scss +++ b/ui/app/styles/components/dropdown.scss @@ -161,33 +161,47 @@ width: 100%; } - .dropdown-option { + // ember-power-select@v4.1.3: There is currently no way to set a class + // on an individual option, so .ember-power-select-option must be included + // in these selectors. + .dropdown-option, + .ember-power-select-option { margin: 0; padding: 0; white-space: nowrap; line-height: 1.75; - label { + label, + .dropdown-label { display: block; padding: 3px 8px; min-width: 100px; cursor: pointer; } - & + .dropdown-option { + & + .dropdown-option, + & + .ember-power-select-option { border-top: 1px solid $grey-lighter; } &:hover, - &:focus { + &:focus, + &[aria-current='true'], + &[aria-selected='true'] { background: $white-bis; + color: $black; outline: none; border-left: 2px solid $blue; - label { + label, + .dropdown-label { padding-left: 6px; min-width: 98px; } } + + &[aria-selected='true'] { + background: $blue-050; + } } .dropdown-empty { diff --git a/ui/app/styles/storybook.scss b/ui/app/styles/storybook.scss index e14d7c167..1a1995cb8 100644 --- a/ui/app/styles/storybook.scss +++ b/ui/app/styles/storybook.scss @@ -54,6 +54,7 @@ } .annotation { + padding: 1rem 0; font-size: 0.9rem; } @@ -186,4 +187,8 @@ color: $grey; font-weight: $weight-bold; } + + .title:not(:first-child) { + margin-top: 2em; + } } diff --git a/ui/app/styles/utils/structure-colors.scss b/ui/app/styles/utils/structure-colors.scss index b62158fa7..9226b7eea 100644 --- a/ui/app/styles/utils/structure-colors.scss +++ b/ui/app/styles/utils/structure-colors.scss @@ -16,6 +16,8 @@ $blue-500: #1563ff; $blue-400: #387aff; $blue-300: #5b92ff; $blue-200: #8ab1ff; +$blue-100: #bfd4ff; +$blue-050: #f0f5ff; $teal-500: #25ba81; $teal-300: #74d3ae; diff --git a/ui/app/templates/components/exec/open-button.hbs b/ui/app/templates/components/exec/open-button.hbs index bb58282d4..00156976a 100644 --- a/ui/app/templates/components/exec/open-button.hbs +++ b/ui/app/templates/components/exec/open-button.hbs @@ -1,4 +1,4 @@ -{{#let (cannot "exec allocation") as |cannotExec|}} +{{#let (cannot "exec allocation" namespace=this.job.namespace) as |cannotExec|}} -{{/let}} \ No newline at end of file +{{/let}} + diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs index d6fdcdc7a..d23993a18 100644 --- a/ui/app/templates/components/gutter-menu.hbs +++ b/ui/app/templates/components/gutter-menu.hbs @@ -23,27 +23,6 @@ {{/if}} - {{#if this.system.shouldShowNamespaces}} - - {{/if}} @@ -51,7 +30,6 @@
  • Jobs @@ -75,7 +53,6 @@
  • Storage Beta diff --git a/ui/app/templates/components/job-page/periodic-child.hbs b/ui/app/templates/components/job-page/periodic-child.hbs index 5d1af86b1..154e9c3ff 100644 --- a/ui/app/templates/components/job-page/periodic-child.hbs +++ b/ui/app/templates/components/job-page/periodic-child.hbs @@ -9,7 +9,7 @@ Priority: {{this.job.priority}} Parent: - + {{this.job.parent.name}} diff --git a/ui/app/templates/components/job-row.hbs b/ui/app/templates/components/job-row.hbs index c879a1d84..6475ab620 100644 --- a/ui/app/templates/components/job-row.hbs +++ b/ui/app/templates/components/job-row.hbs @@ -1,4 +1,7 @@ -{{this.job.name}} +{{this.job.name}} +{{#if this.system.shouldShowNamespaces}} + {{this.job.namespace.name}} +{{/if}} {{#if (eq @context "child")}} {{format-month-ts this.job.submitTime}} {{/if}} diff --git a/ui/app/templates/csi/volumes/index.hbs b/ui/app/templates/csi/volumes/index.hbs index 2ea61d15b..faebf23e6 100644 --- a/ui/app/templates/csi/volumes/index.hbs +++ b/ui/app/templates/csi/volumes/index.hbs @@ -6,20 +6,35 @@
    +
    +
    + {{#if this.visibleVolumes.length}} + + {{/if}} +
    + {{#if this.system.shouldShowNamespaces}} +
    +
    + +
    +
    + {{/if}} +
    {{#if this.isForbidden}} {{else}} -
    -
    - {{#if this.model.length}} - - {{/if}} -
    -
    {{#if this.sortedVolumes}} Name + {{#if this.system.shouldShowNamespaces}} + Namespace + {{/if}} Volume Health Controller Health Node Health @@ -41,8 +59,17 @@ - {{row.model.name}} + + {{row.model.name}} + + {{#if this.system.shouldShowNamespaces}} + {{row.model.namespace.name}} + {{/if}} {{if row.model.schedulable "Schedulable" "Unschedulable"}} {{#if row.model.controllerRequired}} @@ -80,10 +107,10 @@ {{else}}
    - {{#if (eq this.model.length 0)}} + {{#if (eq this.visibleVolumes.length 0)}}

    No Volumes

    - The cluster currently has no CSI Volumes. + This namespace currently has no CSI Volumes.

    {{else if this.searchTerm}}

    No Matches

    diff --git a/ui/app/templates/jobs/index.hbs b/ui/app/templates/jobs/index.hbs index b5979a7ca..a9511e363 100644 --- a/ui/app/templates/jobs/index.hbs +++ b/ui/app/templates/jobs/index.hbs @@ -1,77 +1,88 @@ {{page-title "Jobs"}}
    +
    +
    + {{#if this.visibleJobs.length}} + + {{/if}} +
    + {{#if (media "isMobile")}} +
    + {{#if (can "run job")}} + Run Job + {{else}} + + {{/if}} +
    + {{/if}} +
    +
    + {{#if this.system.shouldShowNamespaces}} + + {{/if}} + + + + +
    +
    + {{#if (not (media "isMobile"))}} +
    + {{#if (can "run job")}} + Run Job + {{else}} + + {{/if}} +
    + {{/if}} +
    {{#if this.isForbidden}} {{else}} -
    -
    - {{#if this.visibleJobs.length}} - - {{/if}} -
    - {{#if (media "isMobile")}} -
    - {{#if (can "run job")}} - Run Job - {{else}} - - {{/if}} -
    - {{/if}} -
    -
    - - - - -
    -
    - {{#if (not (media "isMobile"))}} -
    - {{#if (can "run job")}} - Run Job - {{else}} - - {{/if}} -
    - {{/if}} -
    {{#if this.sortedJobs}} Name + {{#if this.system.shouldShowNamespaces}} + Namespace + {{/if}} Status Type Priority diff --git a/ui/app/templates/optimize.hbs b/ui/app/templates/optimize.hbs index e26b10f3f..a922cbca5 100644 --- a/ui/app/templates/optimize.hbs +++ b/ui/app/templates/optimize.hbs @@ -1,18 +1,29 @@
    - {{#if @model}} + {{#if this.summaries}}
    - {{#if @model}} + {{#if this.summaries}} + @placeholder="Search {{this.summaries.length}} {{pluralize "recommendation" this.summaries.length}}..." /> {{/if}}
    + {{#if this.system.shouldShowNamespaces}} + + {{/if}}
    - {{#if this.system.shouldShowNamespaces}} -
    -
    - -
    Include all namespaces
    -
    -
    -
    - {{/if}} {{#if this.filteredSummaries}} {{outlet}} diff --git a/ui/app/utils/qp-serialize.js b/ui/app/utils/qp-serialize.js index af2d5769e..7717efc28 100644 --- a/ui/app/utils/qp-serialize.js +++ b/ui/app/utils/qp-serialize.js @@ -1,7 +1,10 @@ import { computed } from '@ember/object'; // An unattractive but robust way to encode query params -export const serialize = arr => (arr.length ? JSON.stringify(arr) : ''); +export const serialize = val => { + if (typeof val === 'string' || typeof val === 'number') return val; + return val.length ? JSON.stringify(val) : ''; +}; export const deserialize = str => { try { diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 2cee56d13..fd2da7139 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -57,11 +57,12 @@ export default function() { const json = this.serialize(jobs.all()); const namespace = queryParams.namespace || 'default'; return json - .filter(job => - namespace === 'default' + .filter(job => { + if (namespace === '*') return true; + return namespace === 'default' ? !job.NamespaceID || job.NamespaceID === namespace - : job.NamespaceID === namespace - ) + : job.NamespaceID === namespace; + }) .map(job => filterKeys(job, 'TaskGroups', 'NamespaceID')); }) ); @@ -262,29 +263,33 @@ export default function() { const json = this.serialize(csiVolumes.all()); const namespace = queryParams.namespace || 'default'; - return json.filter(volume => - namespace === 'default' + return json.filter(volume => { + if (namespace === '*') return true; + return namespace === 'default' ? !volume.NamespaceID || volume.NamespaceID === namespace - : volume.NamespaceID === namespace - ); + : volume.NamespaceID === namespace; + }); }) ); this.get( '/volume/:id', - withBlockingSupport(function({ csiVolumes }, { params }) { + withBlockingSupport(function({ csiVolumes }, { params, queryParams }) { if (!params.id.startsWith('csi/')) { return new Response(404, {}, null); } const id = params.id.replace(/^csi\//, ''); - const volume = csiVolumes.find(id); + const volume = csiVolumes.all().models.find(volume => { + const volumeIsDefault = !volume.namespaceId || volume.namespaceId === 'default'; + const qpIsDefault = !queryParams.namespace || queryParams.namespace === 'default'; + return ( + volume.id === id && + (volume.namespaceId === queryParams.namespace || (volumeIsDefault && qpIsDefault)) + ); + }); - if (!volume) { - return new Response(404, {}, null); - } - - return this.serialize(volume); + return volume ? this.serialize(volume) : new Response(404, {}, null); }) ); @@ -318,15 +323,11 @@ export default function() { return this.serialize(records); } - return new Response(501, {}, null); + return this.serialize([{ Name: 'default' }]); }); this.get('/namespace/:id', function({ namespaces }, { params }) { - if (namespaces.all().length) { - return this.serialize(namespaces.find(params.id)); - } - - return new Response(501, {}, null); + return this.serialize(namespaces.find(params.id)); }); this.get('/agent/members', function({ agents, regions }) { diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index e6b5c1da2..8d2423f86 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -39,6 +39,7 @@ export default function(server) { // Scenarios function smallCluster(server) { + server.create('feature', { name: 'Dynamic Application Sizing' }); server.createList('agent', 3); server.createList('node', 5); server.createList('job', 5, { createRecommendations: true }); diff --git a/ui/stories/components/filter-facets.stories.js b/ui/stories/components/filter-facets.stories.js new file mode 100644 index 000000000..b85848bc6 --- /dev/null +++ b/ui/stories/components/filter-facets.stories.js @@ -0,0 +1,207 @@ +import hbs from 'htmlbars-inline-precompile'; + +export default { + title: 'Components|Filter Facets', +}; + +let options1 = [ + { key: 'option-1', label: 'Option One' }, + { key: 'option-2', label: 'Option Two' }, + { key: 'option-3', label: 'Option Three' }, + { key: 'option-4', label: 'Option Four' }, + { key: 'option-5', label: 'Option Five' }, +]; + +let selection1 = ['option-2', 'option-4', 'option-5']; + +export let MultiSelect = () => { + return { + template: hbs` +
    Multi-Select Dropdown
    + +

    A wrapper around basic-dropdown for creating a list of checkboxes and tracking the state thereof.

    + `, + context: { + options1, + selection1, + }, + }; +}; + +export let SingleSelect = () => ({ + template: hbs` +
    Single-Select Dropdown
    + + `, + context: { + options1, + selection: 'option-2', + }, +}); + +export let RightAligned = () => { + return { + template: hbs` +
    Multi-Select Dropdown right-aligned
    +
    + +
    + `, + context: { + options1, + selection1, + }, + }; +}; + +export let ManyOptionsMulti = () => { + return { + template: hbs` +
    Multi-Select Dropdown with many options
    + +

    + A strength of the multi-select-dropdown is its simple presentation. It is quick to select options and it is quick to remove options. + However, this strength becomes a weakness when there are too many options. Since the selection isn't pinned in any way, removing a selection + can become an adventure of scrolling up and down. Also since the selection isn't pinned, this component can't support search, since search would + entirely mask the selection. +

    + `, + context: { + optionsMany: Array(100) + .fill(null) + .map((_, i) => ({ label: `Option ${i}`, key: `option-${i}` })), + selectionMany: [], + }, + }; +}; + +export let ManyOptionsSingle = () => { + return { + template: hbs` +
    Single-Select Dropdown with many options
    + +

    + Single select supports search at a certain option threshold via Ember Power Select. +

    + `, + context: { + optionsMany: Array(100) + .fill(null) + .map((_, i) => ({ label: `Option ${i}`, key: `option-${i}` })), + selection: 'option-1', + }, + }; +}; + +export let Bar = () => { + return { + template: hbs` +
    Multi-Select Dropdown bar
    +
    + + + +
    +
    Single-Select Dropdown bar
    +
    + + + +
    +
    Mixed Dropdown bar
    +
    + + + +
    +

    + Since this is a core component for faceted search, it makes sense to letruct an arrangement of multi-select dropdowns. + Do this by wrapping all the options in a .button-bar container. +

    + `, + context: { + optionsDatacenter: [ + { key: 'pdx-1', label: 'pdx-1' }, + { key: 'jfk-1', label: 'jfk-1' }, + { key: 'jfk-2', label: 'jfk-2' }, + { key: 'muc-1', label: 'muc-1' }, + ], + selectionDatacenter: ['jfk-1'], + selectionDatacenterSingle: 'jfk-1', + + optionsType: [ + { key: 'batch', label: 'Batch' }, + { key: 'service', label: 'Service' }, + { key: 'system', label: 'System' }, + { key: 'periodic', label: 'Periodic' }, + { key: 'parameterized', label: 'Parameterized' }, + ], + selectionType: ['system', 'service'], + selectionTypeSingle: 'system', + + optionsStatus: [ + { key: 'pending', label: 'Pending' }, + { key: 'running', label: 'Running' }, + { key: 'dead', label: 'Dead' }, + ], + selectionStatus: [], + selectionStatusSingle: 'dead', + }, + }; +}; diff --git a/ui/stories/components/multi-select-dropdown.stories.js b/ui/stories/components/multi-select-dropdown.stories.js deleted file mode 100644 index e20d0e84e..000000000 --- a/ui/stories/components/multi-select-dropdown.stories.js +++ /dev/null @@ -1,131 +0,0 @@ -import hbs from 'htmlbars-inline-precompile'; - -export default { - title: 'Components|Multi-Select Dropdown', -}; - -let options1 = [ - { key: 'option-1', label: 'Option One' }, - { key: 'option-2', label: 'Option Two' }, - { key: 'option-3', label: 'Option Three' }, - { key: 'option-4', label: 'Option Four' }, - { key: 'option-5', label: 'Option Five' }, -]; - -let selection1 = ['option-2', 'option-4', 'option-5']; - -export let Standard = () => { - return { - template: hbs` -
    Multi-Select Dropdown
    - -

    A wrapper around basic-dropdown for creating a list of checkboxes and tracking the state thereof.

    - `, - context: { - options1, - selection1, - }, - }; -}; - -export let RightAligned = () => { - return { - template: hbs` -
    Multi-Select Dropdown right-aligned
    -
    - -
    - `, - context: { - options1, - selection1, - }, - }; -}; - -export let ManyOptions = () => { - return { - template: hbs` -
    Multi-Select Dropdown with many options
    - -

    - A strength of the multi-select-dropdown is its simple presentation. It is quick to select options and it is quick to remove options. - However, this strength becomes a weakness when there are too many options. Since the selection isn't pinned in any way, removing a selection - can become an adventure of scrolling up and down. Also since the selection isn't pinned, this component can't support search, since search would - entirely mask the selection. -

    - `, - context: { - optionsMany: Array(100) - .fill(null) - .map((_, i) => ({ label: `Option ${i}`, key: `option-${i}` })), - selectionMany: [], - }, - }; -}; - -export let Bar = () => { - return { - template: hbs` -
    Multi-Select Dropdown bar
    -
    - - - -
    -

    - Since this is a core component for faceted search, it makes sense to letruct an arrangement of multi-select dropdowns. - Do this by wrapping all the options in a .button-bar container. -

    - `, - context: { - optionsDatacenter: [ - { key: 'pdx-1', label: 'pdx-1' }, - { key: 'jfk-1', label: 'jfk-1' }, - { key: 'jfk-2', label: 'jfk-2' }, - { key: 'muc-1', label: 'muc-1' }, - ], - selectionDatacenter: ['jfk-1', 'jfk-2'], - - optionsType: [ - { key: 'batch', label: 'Batch' }, - { key: 'service', label: 'Service' }, - { key: 'system', label: 'System' }, - { key: 'periodic', label: 'Periodic' }, - { key: 'parameterized', label: 'Parameterized' }, - ], - selectionType: ['system', 'service'], - - optionsStatus: [ - { key: 'pending', label: 'Pending' }, - { key: 'running', label: 'Running' }, - { key: 'dead', label: 'Dead' }, - ], - selectionStatus: [], - }, - }; -}; diff --git a/ui/tests/acceptance/job-detail-test.js b/ui/tests/acceptance/job-detail-test.js index 07ea9408b..ebbeea015 100644 --- a/ui/tests/acceptance/job-detail-test.js +++ b/ui/tests/acceptance/job-detail-test.js @@ -2,13 +2,11 @@ import { currentURL } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { selectChoose } from 'ember-power-select/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support'; import moment from 'moment'; import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; import moduleForJob from 'nomad-ui/tests/helpers/module-for-job'; import JobDetail from 'nomad-ui/tests/pages/jobs/detail'; -import JobsList from 'nomad-ui/tests/pages/jobs/list'; moduleForJob('Acceptance | job detail (batch)', 'allocations', () => server.create('job', { type: 'batch', shallow: true }) @@ -128,27 +126,6 @@ module('Acceptance | job detail (with namespaces)', function(hooks) { assert.ok(JobDetail.statFor('namespace').text, 'Namespace included in stats'); }); - test('when switching namespaces, the app redirects to /jobs with the new namespace', async function(assert) { - const namespace = server.db.namespaces.find(job.namespaceId); - const otherNamespace = server.db.namespaces.toArray().find(ns => ns !== namespace).name; - - await JobDetail.visit({ id: job.id, namespace: namespace.name }); - - // TODO: Migrate to Page Objects - await selectChoose('[data-test-namespace-switcher-parent]', otherNamespace); - assert.equal(currentURL().split('?')[0], '/jobs', 'Navigated to /jobs'); - - const jobs = server.db.jobs - .where({ namespace: otherNamespace }) - .sortBy('modifyIndex') - .reverse(); - - assert.equal(JobsList.jobs.length, jobs.length, 'Shows the right number of jobs'); - JobsList.jobs.forEach((jobRow, index) => { - assert.equal(jobRow.name, jobs[index].name, `Job ${index} is right`); - }); - }); - test('the exec button state can change between namespaces', async function(assert) { const job1 = server.create('job', { status: 'running', diff --git a/ui/tests/acceptance/jobs-list-test.js b/ui/tests/acceptance/jobs-list-test.js index 5100fc2f9..538225a43 100644 --- a/ui/tests/acceptance/jobs-list-test.js +++ b/ui/tests/acceptance/jobs-list-test.js @@ -59,6 +59,7 @@ module('Acceptance | jobs list', function(hooks) { const jobRow = JobsList.jobs.objectAt(0); assert.equal(jobRow.name, job.name, 'Name'); + assert.notOk(jobRow.hasNamespace); assert.equal(jobRow.link, `/ui/jobs/${job.id}`, 'Detail Link'); assert.equal(jobRow.status, job.status, 'Status'); assert.equal(jobRow.type, typeForJob(job), 'Type'); @@ -93,41 +94,6 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(currentURL(), '/jobs'); }); - test('the job run button state can change between namespaces', async function(assert) { - server.createList('namespace', 2); - const job1 = server.create('job', { namespaceId: server.db.namespaces[0].id }); - const job2 = server.create('job', { namespaceId: server.db.namespaces[1].id }); - - window.localStorage.nomadTokenSecret = clientToken.secretId; - - const policy = server.create('policy', { - id: 'something', - name: 'something', - rulesJSON: { - Namespaces: [ - { - Name: job1.namespaceId, - Capabilities: ['list-jobs', 'submit-job'], - }, - { - Name: job2.namespaceId, - Capabilities: ['list-jobs'], - }, - ], - }, - }); - - clientToken.policyIds = [policy.id]; - clientToken.save(); - - await JobsList.visit(); - assert.notOk(JobsList.runJobButton.isDisabled); - - const secondNamespace = server.db.namespaces[1]; - await JobsList.visit({ namespace: secondNamespace.id }); - assert.ok(JobsList.runJobButton.isDisabled); - }); - test('the anonymous policy is fetched to check whether to show the job run button', async function(assert) { window.localStorage.removeItem('nomadTokenSecret'); @@ -179,7 +145,18 @@ module('Acceptance | jobs list', function(hooks) { assert.equal(currentURL(), '/jobs?search=foobar', 'No page query param'); }); - test('when the namespace query param is set, only matching jobs are shown and the namespace value is forwarded to app state', async function(assert) { + test('when a cluster has namespaces, each job row includes the job namespace', async function(assert) { + server.createList('namespace', 2); + server.createList('job', 2); + const job = server.db.jobs.sortBy('modifyIndex').reverse()[0]; + + await JobsList.visit({ namespace: '*' }); + + const jobRow = JobsList.jobs.objectAt(0); + assert.equal(jobRow.namespace, job.namespaceId); + }); + + test('when the namespace query param is set, only matching jobs are shown', async function(assert) { server.createList('namespace', 2); const job1 = server.create('job', { namespaceId: server.db.namespaces[0].id }); const job2 = server.create('job', { namespaceId: server.db.namespaces[1].id }); @@ -213,12 +190,30 @@ module('Acceptance | jobs list', function(hooks) { test('the jobs list page has appropriate faceted search options', async function(assert) { await JobsList.visit(); + assert.ok(JobsList.facets.namespace.isHidden, 'Namespace facet not found (no namespaces)'); 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'); }); + testSingleSelectFacet('Namespace', { + facet: JobsList.facets.namespace, + paramName: 'namespace', + expectedOptions: ['All (*)', 'default', 'namespace-2'], + optionToSelect: 'namespace-2', + async beforeEach() { + server.create('namespace', { id: 'default' }); + server.create('namespace', { id: 'namespace-2' }); + server.createList('job', 2, { namespaceId: 'default' }); + server.createList('job', 2, { namespaceId: 'namespace-2' }); + await JobsList.visit(); + }, + filter(job, selection) { + return job.namespaceId === selection; + }, + }); + testFacet('Type', { facet: JobsList.facets.type, paramName: 'type', @@ -352,7 +347,9 @@ module('Acceptance | jobs list', function(hooks) { server.createList('namespace', 2); const namespace = server.db.namespaces[1]; - await JobsList.visit({ namespace: namespace.id }); + await JobsList.visit(); + await JobsList.facets.namespace.toggle(); + await JobsList.facets.namespace.options.objectAt(2).select(); await Layout.gutter.visitStorage(); @@ -369,24 +366,73 @@ module('Acceptance | jobs list', function(hooks) { }, }); - function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) { + 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 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(); - let expectation; - if (typeof expectedOptions === 'function') { - expectation = expectedOptions(server.db.jobs); - } else { - expectation = expectedOptions; - } + const option = facet.options.findOneBy('label', optionToSelect); + const selection = option.key; + await option.select(); - assert.deepEqual( - facet.options.map(option => option.label.trim()), - expectation, - 'Options for facet are as expected' + 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.key; + await option.select(); + + assert.ok( + currentURL().includes(`${paramName}=${selection}`), + 'URL has the correct query param key and value' ); }); + } + + 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; @@ -452,9 +498,8 @@ module('Acceptance | jobs list', function(hooks) { await option2.toggle(); selection.push(option2.key); - assert.equal( - currentURL(), - `/jobs?${paramName}=${encodeURIComponent(JSON.stringify(selection))}`, + assert.ok( + currentURL().includes(encodeURIComponent(JSON.stringify(selection))), 'URL has the correct query param key and value' ); }); diff --git a/ui/tests/acceptance/namespaces-test.js b/ui/tests/acceptance/namespaces-test.js deleted file mode 100644 index 52900b29c..000000000 --- a/ui/tests/acceptance/namespaces-test.js +++ /dev/null @@ -1,174 +0,0 @@ -import { currentURL } from '@ember/test-helpers'; -import { module, test } from 'qunit'; -import { setupApplicationTest } from 'ember-qunit'; -import { setupMirage } from 'ember-cli-mirage/test-support'; -import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; -import { selectChoose } from 'ember-power-select/test-support'; -import JobsList from 'nomad-ui/tests/pages/jobs/list'; -import ClientsList from 'nomad-ui/tests/pages/clients/list'; -import Allocation from 'nomad-ui/tests/pages/allocations/detail'; -import PluginsList from 'nomad-ui/tests/pages/storage/plugins/list'; -import VolumesList from 'nomad-ui/tests/pages/storage/volumes/list'; - -module('Acceptance | namespaces (disabled)', function(hooks) { - setupApplicationTest(hooks); - setupMirage(hooks); - - hooks.beforeEach(function() { - server.create('agent'); - server.create('node'); - server.createList('job', 5, { createAllocations: false }); - }); - - test('the namespace switcher is not in the gutter menu', async function(assert) { - await JobsList.visit(); - assert.notOk(JobsList.namespaceSwitcher.isPresent, 'No namespace switcher found'); - }); - - test('the jobs request is made with no query params', async function(assert) { - await JobsList.visit(); - - const request = server.pretender.handledRequests.findBy('url', '/v1/jobs'); - assert.equal(request.queryParams.namespace, undefined, 'No namespace query param'); - }); -}); - -module('Acceptance | namespaces (enabled)', function(hooks) { - setupApplicationTest(hooks); - setupMirage(hooks); - - hooks.beforeEach(function() { - server.createList('namespace', 3); - server.create('agent'); - server.create('node'); - server.createList('job', 5); - }); - - hooks.afterEach(function() { - window.localStorage.clear(); - }); - - test('it passes an accessibility audit', async function(assert) { - await JobsList.visit(); - await a11yAudit(assert); - }); - - test('the namespace switcher lists all namespaces', async function(assert) { - const namespaces = server.db.namespaces; - - await JobsList.visit(); - - assert.ok(JobsList.namespaceSwitcher.isPresent, 'Namespace switcher found'); - await JobsList.namespaceSwitcher.open(); - // TODO this selector should be scoped to only the namespace switcher options, - // but ember-wormhole makes that difficult. - assert.equal( - JobsList.namespaceSwitcher.options.length, - namespaces.length, - 'All namespaces are in the switcher' - ); - assert.equal( - JobsList.namespaceSwitcher.options.objectAt(0).label, - 'Namespace: default', - 'The first namespace is always the default one' - ); - - const sortedNamespaces = namespaces.slice(1).sortBy('name'); - JobsList.namespaceSwitcher.options.forEach((option, index) => { - // Default Namespace handled separately - if (index === 0) return; - - const namespace = sortedNamespaces[index - 1]; - assert.equal( - option.label, - `Namespace: ${namespace.name}`, - `index ${index}: ${namespace.name}` - ); - }); - }); - - test('changing the namespace sets the namespace in localStorage', async function(assert) { - const namespace = server.db.namespaces[1]; - - await JobsList.visit(); - await selectChoose('[data-test-namespace-switcher-parent]', namespace.name); - - assert.equal( - window.localStorage.nomadActiveNamespace, - namespace.id, - 'Active namespace was set' - ); - }); - - test('changing the namespace refreshes the jobs list when on the jobs page', async function(assert) { - const namespace = server.db.namespaces[1]; - - await JobsList.visit(); - - let requests = server.pretender.handledRequests.filter(req => req.url.startsWith('/v1/jobs')); - assert.equal(requests.length, 1, 'First request to jobs'); - assert.equal( - requests[0].queryParams.namespace, - undefined, - 'Namespace query param is defaulted to "default"/undefined' - ); - - // TODO: handle this with Page Objects - await selectChoose('[data-test-namespace-switcher-parent]', namespace.name); - - requests = server.pretender.handledRequests.filter(req => req.url.startsWith('/v1/jobs')); - assert.equal(requests.length, 2, 'Second request to jobs'); - assert.equal( - requests[1].queryParams.namespace, - namespace.name, - 'Namespace query param on second request' - ); - }); - - test('changing the namespace in the clients hierarchy navigates to the jobs page', async function(assert) { - const namespace = server.db.namespaces[1]; - - await ClientsList.visit(); - await selectChoose('[data-test-namespace-switcher-parent]', namespace.name); - - assert.equal(currentURL(), `/jobs?namespace=${namespace.name}`); - }); - - test('changing the namespace in the allocations hierarchy navigates to the jobs page', async function(assert) { - const namespace = server.db.namespaces[1]; - const allocation = server.create('allocation', { job: server.db.jobs[0] }); - - await Allocation.visit({ id: allocation.id }); - await selectChoose('[data-test-namespace-switcher-parent]', namespace.name); - - assert.equal(currentURL(), `/jobs?namespace=${namespace.name}`); - }); - - test('changing the namespace in the storage hierarchy navigates to the volumes page', async function(assert) { - const namespace = server.db.namespaces[1]; - - await PluginsList.visit(); - await selectChoose('[data-test-namespace-switcher-parent]', namespace.name); - - assert.equal(currentURL(), `/csi/volumes?namespace=${namespace.name}`); - }); - - test('changing the namespace refreshes the volumes list when on the volumes page', async function(assert) { - const namespace = server.db.namespaces[1]; - - await VolumesList.visit(); - - let requests = server.pretender.handledRequests.filter(req => - req.url.startsWith('/v1/volumes') - ); - assert.equal(requests.length, 1); - assert.equal(requests[0].queryParams.namespace, undefined); - - // TODO: handle this with Page Objects - await selectChoose('[data-test-namespace-switcher-parent]', namespace.name); - - requests = server.pretender.handledRequests.filter(req => req.url.startsWith('/v1/volumes')); - assert.equal(requests.length, 2); - assert.equal(requests[1].queryParams.namespace, namespace.name); - }); -}); diff --git a/ui/tests/acceptance/optimize-test.js b/ui/tests/acceptance/optimize-test.js index d645d6963..435d9ae68 100644 --- a/ui/tests/acceptance/optimize-test.js +++ b/ui/tests/acceptance/optimize-test.js @@ -359,7 +359,7 @@ module('Acceptance | optimize', function(hooks) { window.localStorage.nomadTokenSecret = clientToken.secretId; await Optimize.visit(); - assert.equal(currentURL(), '/jobs'); + assert.equal(currentURL(), '/jobs?namespace=default'); assert.ok(Layout.gutter.optimize.isHidden); }); @@ -444,39 +444,6 @@ module('Acceptance | optimize search and facets', function(hooks) { assert.ok(Optimize.recommendationSummaries[0].isActive); }); - test('turning off the namespaces toggle narrows summaries to only the current namespace and changes an active summary if it has become filtered out', async function(assert) { - server.create('job', { - name: 'pppppp', - createRecommendations: true, - groupsCount: 1, - groupTaskCount: 4, - namespaceId: server.db.namespaces[1].id, - }); - - // Ensure this job’s recommendations are sorted to the top of the table - const futureSubmitTime = (Date.now() + 10000) * 1000000; - server.db.recommendations.update({ submitTime: futureSubmitTime }); - - server.create('job', { - name: 'oooooo', - createRecommendations: true, - groupsCount: 1, - groupTaskCount: 6, - namespaceId: server.db.namespaces[0].id, - }); - - await Optimize.visit(); - - assert.ok(Optimize.allNamespacesToggle.isActive); - - await Optimize.allNamespacesToggle.toggle(); - - assert.equal(Optimize.recommendationSummaries.length, 1); - assert.ok(Optimize.recommendationSummaries[0].slug.startsWith('ooo')); - assert.ok(currentURL().includes('all-namespaces=false')); - assert.equal(Optimize.card.slug.jobName, 'oooooo'); - }); - test('the namespaces toggle doesn’t show when there aren’t namespaces', async function(assert) { server.db.namespaces.remove(); @@ -488,7 +455,7 @@ module('Acceptance | optimize search and facets', function(hooks) { await Optimize.visit(); - assert.ok(Optimize.allNamespacesToggle.isHidden); + assert.ok(Optimize.facets.namespace.isHidden); }); test('processing a summary moves to the next one in the sorted list', async function(assert) { @@ -547,12 +514,28 @@ module('Acceptance | optimize search and facets', function(hooks) { await Optimize.visit(); + assert.ok(Optimize.facets.namespace.isPresent, 'Namespace facet found'); assert.ok(Optimize.facets.type.isPresent, 'Type facet found'); assert.ok(Optimize.facets.status.isPresent, 'Status facet found'); assert.ok(Optimize.facets.datacenter.isPresent, 'Datacenter facet found'); assert.ok(Optimize.facets.prefix.isPresent, 'Prefix facet found'); }); + testSingleSelectFacet('Namespace', { + facet: Optimize.facets.namespace, + paramName: 'namespace', + expectedOptions: ['All (*)', 'default', 'namespace-1'], + optionToSelect: 'namespace-1', + async beforeEach() { + server.createList('job', 2, { namespaceId: 'default', createRecommendations: true }); + server.createList('job', 2, { namespaceId: 'namespace-1', createRecommendations: true }); + await Optimize.visit(); + }, + filter(taskGroup, selection) { + return taskGroup.job.namespaceId === selection; + }, + }); + testFacet('Type', { facet: Optimize.facets.type, paramName: 'type', @@ -683,24 +666,73 @@ module('Acceptance | optimize search and facets', function(hooks) { selection.find(prefix => taskGroup.job.name.startsWith(prefix)), }); - function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) { + 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 testSingleSelectFacet( + label, + { facet, paramName, beforeEach, filter, expectedOptions, optionToSelect } + ) { test(`the ${label} facet has the correct options`, async function(assert) { + await facetOptions.call(this, assert, beforeEach, facet, expectedOptions); + }); + + test(`the ${label} facet filters the jobs list by ${label}`, async function(assert) { await beforeEach(); await facet.toggle(); - let expectation; - if (typeof expectedOptions === 'function') { - expectation = expectedOptions(server.db.jobs); - } else { - expectation = expectedOptions; - } + const option = facet.options.findOneBy('label', optionToSelect); + const selection = option.key; + await option.select(); - assert.deepEqual( - facet.options.map(option => option.label.trim()), - expectation, - 'Options for facet are as expected' + const sortedRecommendations = server.db.recommendations.sortBy('submitTime').reverse(); + + const recommendationTaskGroups = server.schema.tasks + .find(sortedRecommendations.mapBy('taskId').uniq()) + .models.mapBy('taskGroup') + .uniqBy('id') + .filter(group => filter(group, selection)); + + Optimize.recommendationSummaries.forEach((summary, index) => { + const group = recommendationTaskGroups[index]; + assert.equal(summary.slug, `${group.job.name} / ${group.name}`); + }); + }); + + 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.key; + await option.select(); + + assert.ok( + currentURL().includes(`${paramName}=${selection}`), + 'URL has the correct query param key and value' ); }); + } + + function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) { + test(`the ${label} facet has the correct options`, async function(assert) { + await facetOptions.call(this, assert, beforeEach, facet, expectedOptions); + }); test(`the ${label} facet filters the recommendation summaries by ${label}`, async function(assert) { let option; diff --git a/ui/tests/acceptance/task-detail-test.js b/ui/tests/acceptance/task-detail-test.js index 8a842c808..4a8a92fd4 100644 --- a/ui/tests/acceptance/task-detail-test.js +++ b/ui/tests/acceptance/task-detail-test.js @@ -298,11 +298,7 @@ module('Acceptance | task detail (different namespace)', function(hooks) { const job = server.db.jobs.find(jobId); await Layout.breadcrumbFor('jobs.index').visit(); - assert.equal( - currentURL(), - '/jobs?namespace=other-namespace', - 'Jobs breadcrumb links correctly' - ); + assert.equal(currentURL(), '/jobs?namespace=default', 'Jobs breadcrumb links correctly'); await Task.visit({ id: allocation.id, name: task.name }); await Layout.breadcrumbFor('jobs.job.index').visit(); diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js index 1c88bc8d0..9a1f3545c 100644 --- a/ui/tests/acceptance/token-test.js +++ b/ui/tests/acceptance/token-test.js @@ -148,23 +148,6 @@ module('Acceptance | tokens', function(hooks) { assert.notOk(find('[data-test-job-row]'), 'No jobs found'); }); - test('when namespaces are enabled, setting or clearing a token refetches namespaces available with new permissions', async function(assert) { - const { secretId } = clientToken; - - server.createList('namespace', 2); - await Tokens.visit(); - - const requests = server.pretender.handledRequests; - - assert.equal(requests.filter(req => req.url === '/v1/namespaces').length, 1); - - await Tokens.secret(secretId).submit(); - assert.equal(requests.filter(req => req.url === '/v1/namespaces').length, 2); - - await Tokens.clear(); - assert.equal(requests.filter(req => req.url === '/v1/namespaces').length, 3); - }); - test('when the ott query parameter is present upon application load it’s exchanged for a token', async function(assert) { const { oneTimeSecret, secretId } = managementToken; diff --git a/ui/tests/acceptance/volume-detail-test.js b/ui/tests/acceptance/volume-detail-test.js index 6b36b000e..2f937356e 100644 --- a/ui/tests/acceptance/volume-detail-test.js +++ b/ui/tests/acceptance/volume-detail-test.js @@ -209,7 +209,7 @@ module('Acceptance | volume detail (with namespaces)', function(hooks) { }); test('/csi/volumes/:id detail ribbon includes the namespace of the volume', async function(assert) { - await VolumeDetail.visit({ id: volume.id }); + await VolumeDetail.visit({ id: volume.id, namespace: volume.namespaceId }); assert.ok(VolumeDetail.hasNamespace); assert.ok(VolumeDetail.namespace.includes(volume.namespaceId || 'default')); diff --git a/ui/tests/acceptance/volumes-list-test.js b/ui/tests/acceptance/volumes-list-test.js index a00057387..193d115f0 100644 --- a/ui/tests/acceptance/volumes-list-test.js +++ b/ui/tests/acceptance/volumes-list-test.js @@ -82,6 +82,7 @@ module('Acceptance | volumes list', function(hooks) { const nodeHealthStr = volume.nodesHealthy > 0 ? 'Healthy' : 'Unhealthy'; assert.equal(volumeRow.name, volume.id); + assert.notOk(volumeRow.hasNamespace); assert.equal(volumeRow.schedulable, volume.schedulable ? 'Schedulable' : 'Unschedulable'); assert.equal(volumeRow.controllerHealth, controllerHealthStr); assert.equal( @@ -93,18 +94,19 @@ module('Acceptance | volumes list', function(hooks) { }); test('each volume row should link to the corresponding volume', async function(assert) { - const volume = server.create('csi-volume'); + const [, secondNamespace] = server.createList('namespace', 2); + const volume = server.create('csi-volume', { namespaceId: secondNamespace.id }); - await VolumesList.visit(); + await VolumesList.visit({ namespace: '*' }); await VolumesList.volumes.objectAt(0).clickName(); - assert.equal(currentURL(), `/csi/volumes/${volume.id}`); + assert.equal(currentURL(), `/csi/volumes/${volume.id}?namespace=${secondNamespace.id}`); - await VolumesList.visit(); - assert.equal(currentURL(), '/csi/volumes'); + await VolumesList.visit({ namespace: '*' }); + assert.equal(currentURL(), '/csi/volumes?namespace=*'); await VolumesList.volumes.objectAt(0).clickRow(); - assert.equal(currentURL(), `/csi/volumes/${volume.id}`); + assert.equal(currentURL(), `/csi/volumes/${volume.id}?namespace=${secondNamespace.id}`); }); test('when there are no volumes, there is an empty message', async function(assert) { @@ -138,6 +140,16 @@ module('Acceptance | volumes list', function(hooks) { assert.equal(currentURL(), '/csi/volumes?search=foobar'); }); + test('when the cluster has namespaces, each volume row includes the volume namespace', async function(assert) { + server.createList('namespace', 2); + const volume = server.create('csi-volume'); + + await VolumesList.visit({ namespace: '*' }); + + const volumeRow = VolumesList.volumes.objectAt(0); + assert.equal(volumeRow.namespace, volume.namespaceId); + }); + test('when the namespace query param is set, only matching volumes are shown and the namespace value is forwarded to app state', async function(assert) { server.createList('namespace', 2); const volume1 = server.create('csi-volume', { namespaceId: server.db.namespaces[0].id }); @@ -159,7 +171,9 @@ module('Acceptance | volumes list', function(hooks) { server.createList('namespace', 2); const namespace = server.db.namespaces[1]; - await VolumesList.visit({ namespace: namespace.id }); + await VolumesList.visit(); + await VolumesList.facets.namespace.toggle(); + await VolumesList.facets.namespace.options.objectAt(2).select(); await Layout.gutter.visitJobs(); @@ -185,4 +199,79 @@ module('Acceptance | volumes list', function(hooks) { await VolumesList.visit(); }, }); + + testSingleSelectFacet('Namespace', { + facet: VolumesList.facets.namespace, + paramName: 'namespace', + expectedOptions: ['All (*)', 'default', 'namespace-2'], + optionToSelect: 'namespace-2', + async beforeEach() { + server.create('namespace', { id: 'default' }); + server.create('namespace', { id: 'namespace-2' }); + server.createList('csi-volume', 2, { namespaceId: 'default' }); + server.createList('csi-volume', 2, { namespaceId: 'namespace-2' }); + await VolumesList.visit(); + }, + filter(volume, selection) { + return volume.namespaceId === selection; + }, + }); + + function testSingleSelectFacet( + label, + { facet, paramName, beforeEach, filter, expectedOptions, optionToSelect } + ) { + test(`the ${label} facet has the correct options`, async function(assert) { + 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' + ); + }); + + test(`the ${label} facet filters the volumes list by ${label}`, async function(assert) { + await beforeEach(); + await facet.toggle(); + + const option = facet.options.findOneBy('label', optionToSelect); + const selection = option.key; + await option.select(); + + const expectedVolumes = server.db.csiVolumes + .filter(volume => filter(volume, selection)) + .sortBy('id'); + + VolumesList.volumes.forEach((volume, index) => { + assert.equal( + volume.name, + expectedVolumes[index].name, + `Volume at ${index} is ${expectedVolumes[index].name}` + ); + }); + }); + + 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.key; + await option.select(); + + assert.ok( + currentURL().includes(`${paramName}=${selection}`), + 'URL has the correct query param key and value' + ); + }); + } }); diff --git a/ui/tests/helpers/setup-ability.js b/ui/tests/helpers/setup-ability.js index 41f56f3a7..c2edd14b7 100644 --- a/ui/tests/helpers/setup-ability.js +++ b/ui/tests/helpers/setup-ability.js @@ -1,9 +1,11 @@ export default ability => hooks => { hooks.beforeEach(function() { this.ability = this.owner.lookup(`ability:${ability}`); + this.can = this.owner.lookup('service:can'); }); hooks.afterEach(function() { delete this.ability; + delete this.can; }); }; diff --git a/ui/tests/integration/components/multi-select-dropdown-test.js b/ui/tests/integration/components/multi-select-dropdown-test.js index 82e2dbac2..1592395f0 100644 --- a/ui/tests/integration/components/multi-select-dropdown-test.js +++ b/ui/tests/integration/components/multi-select-dropdown-test.js @@ -30,10 +30,10 @@ module('Integration | Component | multi-select dropdown', function(hooks) { const commonTemplate = hbs` + @label={{this.label}} + @options={{this.options}} + @selection={{this.selection}} + @onSelect={{this.onSelect}} /> `; test('component is initially closed', async function(assert) { diff --git a/ui/tests/integration/components/single-select-dropdown-test.js b/ui/tests/integration/components/single-select-dropdown-test.js new file mode 100644 index 000000000..3d509de75 --- /dev/null +++ b/ui/tests/integration/components/single-select-dropdown-test.js @@ -0,0 +1,80 @@ +import { findAll, find, render } from '@ember/test-helpers'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { selectChoose, clickTrigger } from 'ember-power-select/test-support/helpers'; +import sinon from 'sinon'; +import hbs from 'htmlbars-inline-precompile'; +import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; + +module('Integration | Component | single-select dropdown', function(hooks) { + setupRenderingTest(hooks); + + const commonProperties = () => ({ + label: 'Type', + selection: 'nomad', + options: [ + { key: 'consul', label: 'Consul' }, + { key: 'nomad', label: 'Nomad' }, + { key: 'terraform', label: 'Terraform' }, + { key: 'packer', label: 'Packer' }, + { key: 'vagrant', label: 'Vagrant' }, + { key: 'vault', label: 'Vault' }, + ], + onSelect: sinon.spy(), + }); + + const commonTemplate = hbs` + + `; + + test('component shows label and selection in the trigger', async function(assert) { + const props = commonProperties(); + this.setProperties(props); + await render(commonTemplate); + + assert.ok(find('.ember-power-select-trigger').textContent.includes(props.label)); + assert.ok( + find('.ember-power-select-trigger').textContent.includes( + props.options.findBy('key', props.selection).label + ) + ); + assert.notOk(find('[data-test-dropdown-options]')); + + await componentA11yAudit(this.element, assert); + }); + + test('all options are shown in the dropdown', async function(assert) { + const props = commonProperties(); + this.setProperties(props); + await render(commonTemplate); + + await clickTrigger('[data-test-single-select-dropdown]'); + + assert.equal( + findAll('.ember-power-select-option').length, + props.options.length, + 'All options are shown' + ); + findAll('.ember-power-select-option').forEach((optionEl, index) => { + assert.equal( + optionEl.querySelector('.dropdown-label').textContent.trim(), + props.options[index].label + ); + }); + }); + + test('selecting an option calls `onSelect` with the key for the selected option', async function(assert) { + const props = commonProperties(); + this.setProperties(props); + await render(commonTemplate); + + const option = props.options.findBy('key', 'terraform'); + await selectChoose('[data-test-single-select-dropdown]', option.label); + + assert.ok(props.onSelect.calledWith(option.key)); + }); +}); diff --git a/ui/tests/pages/clients/list.js b/ui/tests/pages/clients/list.js index c0d04319a..652a777dc 100644 --- a/ui/tests/pages/clients/list.js +++ b/ui/tests/pages/clients/list.js @@ -11,7 +11,7 @@ import { visitable, } from 'ember-cli-page-object'; -import facet from 'nomad-ui/tests/pages/components/facet'; +import { multiFacet } from 'nomad-ui/tests/pages/components/facet'; import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select'; export default create({ @@ -72,9 +72,9 @@ export default create({ }, facets: { - class: facet('[data-test-class-facet]'), - state: facet('[data-test-state-facet]'), - datacenter: facet('[data-test-datacenter-facet]'), - volume: facet('[data-test-volume-facet]'), + class: multiFacet('[data-test-class-facet]'), + state: multiFacet('[data-test-state-facet]'), + datacenter: multiFacet('[data-test-datacenter-facet]'), + volume: multiFacet('[data-test-volume-facet]'), }, }); diff --git a/ui/tests/pages/components/facet.js b/ui/tests/pages/components/facet.js index 5dce211cd..a4d737184 100644 --- a/ui/tests/pages/components/facet.js +++ b/ui/tests/pages/components/facet.js @@ -1,17 +1,38 @@ -import { isPresent, clickable, collection, text, attribute } from 'ember-cli-page-object'; +import { clickable, collection, text, attribute } from 'ember-cli-page-object'; +import { selectChoose, clickTrigger } from 'ember-power-select/test-support/helpers'; -export default scope => ({ +export const multiFacet = scope => ({ scope, - isPresent: isPresent(), - toggle: clickable('[data-test-dropdown-trigger]'), options: collection('[data-test-dropdown-option]', { - testContainer: '#ember-testing', + testContainer: '#ember-testing .ember-basic-dropdown-content', resetScope: true, label: text(), key: attribute('data-test-dropdown-option'), toggle: clickable('label'), }), }); + +export const singleFacet = scope => ({ + scope, + + async toggle() { + await clickTrigger(this.scope); + }, + + options: collection('.ember-power-select-option', { + testContainer: '#ember-testing .ember-basic-dropdown-content', + resetScope: true, + label: text('[data-test-dropdown-option]'), + key: attribute('data-test-dropdown-option', '[data-test-dropdown-option]'), + async select() { + // __parentTreeNode is clearly meant to be private in the page object API, + // but it seems better to use that and keep the API symmetry across singleFacet + // and multiFacet compared to moving select to the parent. + const parentScope = this.__parentTreeNode.scope; + await selectChoose(parentScope, this.label); + }, + }), +}); diff --git a/ui/tests/pages/jobs/list.js b/ui/tests/pages/jobs/list.js index b1eb7faf1..da4e60140 100644 --- a/ui/tests/pages/jobs/list.js +++ b/ui/tests/pages/jobs/list.js @@ -10,7 +10,7 @@ import { visitable, } from 'ember-cli-page-object'; -import facet from 'nomad-ui/tests/pages/components/facet'; +import { multiFacet, singleFacet } from 'nomad-ui/tests/pages/components/facet'; import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select'; export default create({ @@ -31,12 +31,14 @@ export default create({ jobs: collection('[data-test-job-row]', { id: attribute('data-test-job-row'), name: text('[data-test-job-name]'), + namespace: text('[data-test-job-namespace]'), link: attribute('href', '[data-test-job-name] a'), 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'), }), @@ -58,22 +60,13 @@ export default create({ gotoClients: clickable('[data-test-error-clients-link]'), }, - namespaceSwitcher: { - isPresent: isPresent('[data-test-namespace-switcher-parent]'), - open: clickable('[data-test-namespace-switcher-parent] .ember-power-select-trigger'), - options: collection('.ember-power-select-option', { - testContainer: '#ember-testing', - resetScope: true, - label: text(), - }), - }, - pageSizeSelect: pageSizeSelect(), facets: { - type: facet('[data-test-type-facet]'), - status: facet('[data-test-status-facet]'), - datacenter: facet('[data-test-datacenter-facet]'), - prefix: facet('[data-test-prefix-facet]'), + 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]'), }, }); diff --git a/ui/tests/pages/layout.js b/ui/tests/pages/layout.js index e9d3de2c2..2532a933e 100644 --- a/ui/tests/pages/layout.js +++ b/ui/tests/pages/layout.js @@ -50,14 +50,6 @@ export default create({ gutter: { scope: '[data-test-gutter-menu]', - namespaceSwitcher: { - scope: '[data-test-namespace-switcher-parent]', - isPresent: isPresent(), - open: clickable('.ember-power-select-trigger'), - options: collection('.ember-power-select-option', { - label: text(), - }), - }, visitJobs: clickable('[data-test-gutter-link="jobs"]'), optimize: { diff --git a/ui/tests/pages/optimize.js b/ui/tests/pages/optimize.js index ce351ef31..35fbddc4c 100644 --- a/ui/tests/pages/optimize.js +++ b/ui/tests/pages/optimize.js @@ -10,8 +10,7 @@ import { } from 'ember-cli-page-object'; import recommendationCard from 'nomad-ui/tests/pages/components/recommendation-card'; -import facet from 'nomad-ui/tests/pages/components/facet'; -import toggle from 'nomad-ui/tests/pages/components/toggle'; +import { multiFacet, singleFacet } from 'nomad-ui/tests/pages/components/facet'; export default create({ visit: visitable('/optimize'), @@ -22,14 +21,13 @@ export default create({ }, facets: { - type: facet('[data-test-type-facet]'), - status: facet('[data-test-status-facet]'), - datacenter: facet('[data-test-datacenter-facet]'), - prefix: facet('[data-test-prefix-facet]'), + 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]'), }, - allNamespacesToggle: toggle('[data-test-all-namespaces-toggle]'), - card: recommendationCard, recommendationSummaries: collection('[data-test-recommendation-summary-row]', { diff --git a/ui/tests/pages/storage/plugins/plugin/allocations.js b/ui/tests/pages/storage/plugins/plugin/allocations.js index e3f2924f9..bd553fbe4 100644 --- a/ui/tests/pages/storage/plugins/plugin/allocations.js +++ b/ui/tests/pages/storage/plugins/plugin/allocations.js @@ -1,7 +1,7 @@ import { clickable, create, isPresent, text, visitable } from 'ember-cli-page-object'; import allocations from 'nomad-ui/tests/pages/components/allocations'; -import facet from 'nomad-ui/tests/pages/components/facet'; +import { multiFacet } from 'nomad-ui/tests/pages/components/facet'; import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select'; export default create({ @@ -22,7 +22,7 @@ export default create({ pageSizeSelect: pageSizeSelect(), facets: { - health: facet('[data-test-health-facet]'), - type: facet('[data-test-type-facet]'), + health: multiFacet('[data-test-health-facet]'), + type: multiFacet('[data-test-type-facet]'), }, }); diff --git a/ui/tests/pages/storage/volumes/list.js b/ui/tests/pages/storage/volumes/list.js index 3c14f6264..d69e39b6f 100644 --- a/ui/tests/pages/storage/volumes/list.js +++ b/ui/tests/pages/storage/volumes/list.js @@ -9,6 +9,7 @@ import { } from 'ember-cli-page-object'; import error from 'nomad-ui/tests/pages/components/error'; +import { singleFacet } from 'nomad-ui/tests/pages/components/facet'; import pageSizeSelect from 'nomad-ui/tests/pages/components/page-size-select'; export default create({ @@ -20,12 +21,14 @@ export default create({ volumes: collection('[data-test-volume-row]', { name: text('[data-test-volume-name]'), + namespace: text('[data-test-volume-namespace]'), schedulable: text('[data-test-volume-schedulable]'), controllerHealth: text('[data-test-volume-controller-health]'), nodeHealth: text('[data-test-volume-node-health]'), provider: text('[data-test-volume-provider]'), allocations: text('[data-test-volume-allocations]'), + hasNamespace: isPresent('[data-test-volume-namespace]'), clickRow: clickable(), clickName: clickable('[data-test-volume-name] a'), }), @@ -41,13 +44,7 @@ export default create({ error: error(), pageSizeSelect: pageSizeSelect(), - namespaceSwitcher: { - isPresent: isPresent('[data-test-namespace-switcher-parent]'), - open: clickable('[data-test-namespace-switcher-parent] .ember-power-select-trigger'), - options: collection('.ember-power-select-option', { - testContainer: '#ember-testing', - resetScope: true, - label: text(), - }), + facets: { + namespace: singleFacet('[data-test-namespace-facet]'), }, }); diff --git a/ui/tests/unit/abilities/allocation-test.js b/ui/tests/unit/abilities/allocation-test.js index 174db388c..27eb574de 100644 --- a/ui/tests/unit/abilities/allocation-test.js +++ b/ui/tests/unit/abilities/allocation-test.js @@ -15,7 +15,7 @@ module('Unit | Ability | allocation', function(hooks) { this.owner.register('service:token', mockToken); - assert.ok(this.ability.canExec); + assert.ok(this.can.can('exec allocation')); }); test('it permits alloc exec for management tokens', function(assert) { @@ -26,15 +26,12 @@ module('Unit | Ability | allocation', function(hooks) { this.owner.register('service:token', mockToken); - assert.ok(this.ability.canExec); + assert.ok(this.can.can('exec allocation')); }); test('it permits alloc exec for client tokens with a policy that has namespace alloc-exec', function(assert) { const mockSystem = Service.extend({ aclEnabled: true, - activeNamespace: { - name: 'aNamespace', - }, }); const mockToken = Service.extend({ @@ -57,15 +54,12 @@ module('Unit | Ability | allocation', function(hooks) { this.owner.register('service:system', mockSystem); this.owner.register('service:token', mockToken); - assert.ok(this.ability.canExec); + assert.ok(this.can.can('exec allocation', null, { namespace: 'aNamespace' })); }); test('it permits alloc exec for client tokens with a policy that has default namespace alloc-exec and no capabilities for active namespace', function(assert) { const mockSystem = Service.extend({ aclEnabled: true, - activeNamespace: { - name: 'anotherNamespace', - }, }); const mockToken = Service.extend({ @@ -92,15 +86,12 @@ module('Unit | Ability | allocation', function(hooks) { this.owner.register('service:system', mockSystem); this.owner.register('service:token', mockToken); - assert.ok(this.ability.canExec); + assert.ok(this.can.can('exec allocation', null, { namespace: 'anotherNamespace' })); }); test('it blocks alloc exec for client tokens with a policy that has no alloc-exec capability', function(assert) { const mockSystem = Service.extend({ aclEnabled: true, - activeNamespace: { - name: 'aNamespace', - }, }); const mockToken = Service.extend({ @@ -123,15 +114,12 @@ module('Unit | Ability | allocation', function(hooks) { this.owner.register('service:system', mockSystem); this.owner.register('service:token', mockToken); - assert.notOk(this.ability.canExec); + assert.ok(this.can.cannot('exec allocation', null, { namespace: 'aNamespace' })); }); test('it handles globs in namespace names', function(assert) { const mockSystem = Service.extend({ aclEnabled: true, - activeNamespace: { - name: 'aNamespace', - }, }); const mockToken = Service.extend({ @@ -174,27 +162,17 @@ module('Unit | Ability | allocation', function(hooks) { this.owner.register('service:system', mockSystem); this.owner.register('service:token', mockToken); - const systemService = this.owner.lookup('service:system'); - - systemService.set('activeNamespace.name', 'production-web'); - assert.notOk(this.ability.canExec); - - systemService.set('activeNamespace.name', 'production-api'); - assert.ok(this.ability.canExec); - - systemService.set('activeNamespace.name', 'production-other'); - assert.ok(this.ability.canExec); - - systemService.set('activeNamespace.name', 'something-suffixed'); - assert.ok(this.ability.canExec); - - systemService.set('activeNamespace.name', 'something-more-suffixed'); - assert.notOk( - this.ability.canExec, + assert.ok(this.can.cannot('exec allocation', null, { namespace: 'production-web' })); + assert.ok(this.can.can('exec allocation', null, { namespace: 'production-api' })); + assert.ok(this.can.can('exec allocation', null, { namespace: 'production-other' })); + assert.ok(this.can.can('exec allocation', null, { namespace: 'something-suffixed' })); + assert.ok( + this.can.cannot('exec allocation', null, { namespace: 'something-more-suffixed' }), 'expected the namespace with the greatest number of matched characters to be chosen' ); - - systemService.set('activeNamespace.name', '000-abc-999'); - assert.ok(this.ability.canExec, 'expected to be able to match against more than one wildcard'); + assert.ok( + this.can.can('exec allocation', null, { namespace: '000-abc-999' }), + 'expected to be able to match against more than one wildcard' + ); }); }); diff --git a/ui/tests/unit/abilities/job-test.js b/ui/tests/unit/abilities/job-test.js index b7a874a02..437c1afa4 100644 --- a/ui/tests/unit/abilities/job-test.js +++ b/ui/tests/unit/abilities/job-test.js @@ -32,9 +32,6 @@ module('Unit | Ability | job', function(hooks) { test('it permits job run for client tokens with a policy that has namespace submit-job', function(assert) { const mockSystem = Service.extend({ aclEnabled: true, - activeNamespace: { - name: 'aNamespace', - }, }); const mockToken = Service.extend({ @@ -57,15 +54,12 @@ module('Unit | Ability | job', function(hooks) { this.owner.register('service:system', mockSystem); this.owner.register('service:token', mockToken); - assert.ok(this.ability.canRun); + assert.ok(this.can.can('run job', null, { namespace: 'aNamespace' })); }); test('it permits job run for client tokens with a policy that has default namespace submit-job and no capabilities for active namespace', function(assert) { const mockSystem = Service.extend({ aclEnabled: true, - activeNamespace: { - name: 'anotherNamespace', - }, }); const mockToken = Service.extend({ @@ -92,15 +86,12 @@ module('Unit | Ability | job', function(hooks) { this.owner.register('service:system', mockSystem); this.owner.register('service:token', mockToken); - assert.ok(this.ability.canRun); + assert.ok(this.can.can('run job', null, { namespace: 'anotherNamespace' })); }); test('it blocks job run for client tokens with a policy that has no submit-job capability', function(assert) { const mockSystem = Service.extend({ aclEnabled: true, - activeNamespace: { - name: 'aNamespace', - }, }); const mockToken = Service.extend({ @@ -123,7 +114,7 @@ module('Unit | Ability | job', function(hooks) { this.owner.register('service:system', mockSystem); this.owner.register('service:token', mockToken); - assert.notOk(this.ability.canRun); + assert.ok(this.can.cannot('run job', null, { namespace: 'aNamespace' })); }); test('job scale requires a client token with the submit-job or scale-job capability', function(assert) { @@ -142,9 +133,6 @@ module('Unit | Ability | job', function(hooks) { const mockSystem = Service.extend({ aclEnabled: true, - activeNamespace: { - name: 'aNamespace', - }, }); const mockToken = Service.extend({ @@ -157,24 +145,21 @@ module('Unit | Ability | job', function(hooks) { this.owner.register('service:token', mockToken); const tokenService = this.owner.lookup('service:token'); - assert.notOk(this.ability.canScale); + assert.ok(this.can.cannot('scale job', null, { namespace: 'aNamespace' })); tokenService.set('selfTokenPolicies', makePolicies('aNamespace', 'scale-job')); - assert.ok(this.ability.canScale); + assert.ok(this.can.can('scale job', null, { namespace: 'aNamespace' })); tokenService.set('selfTokenPolicies', makePolicies('aNamespace', 'submit-job')); - assert.ok(this.ability.canScale); + assert.ok(this.can.can('scale job', null, { namespace: 'aNamespace' })); tokenService.set('selfTokenPolicies', makePolicies('bNamespace', 'scale-job')); - assert.notOk(this.ability.canScale); + assert.ok(this.can.cannot('scale job', null, { namespace: 'aNamespace' })); }); test('it handles globs in namespace names', function(assert) { const mockSystem = Service.extend({ aclEnabled: true, - activeNamespace: { - name: 'aNamespace', - }, }); const mockToken = Service.extend({ @@ -217,27 +202,17 @@ module('Unit | Ability | job', function(hooks) { this.owner.register('service:system', mockSystem); this.owner.register('service:token', mockToken); - const systemService = this.owner.lookup('service:system'); - - systemService.set('activeNamespace.name', 'production-web'); - assert.notOk(this.ability.canRun); - - systemService.set('activeNamespace.name', 'production-api'); - assert.ok(this.ability.canRun); - - systemService.set('activeNamespace.name', 'production-other'); - assert.ok(this.ability.canRun); - - systemService.set('activeNamespace.name', 'something-suffixed'); - assert.ok(this.ability.canRun); - - systemService.set('activeNamespace.name', 'something-more-suffixed'); - assert.notOk( - this.ability.canRun, + assert.ok(this.can.cannot('run job', null, { namespace: 'production-web' })); + assert.ok(this.can.can('run job', null, { namespace: 'production-api' })); + assert.ok(this.can.can('run job', null, { namespace: 'production-other' })); + assert.ok(this.can.can('run job', null, { namespace: 'something-suffixed' })); + assert.ok( + this.can.cannot('run job', null, { namespace: 'something-more-suffixed' }), 'expected the namespace with the greatest number of matched characters to be chosen' ); - - systemService.set('activeNamespace.name', '000-abc-999'); - assert.ok(this.ability.canRun, 'expected to be able to match against more than one wildcard'); + assert.ok( + this.can.can('run job', null, { namespace: '000-abc-999' }), + 'expected to be able to match against more than one wildcard' + ); }); }); diff --git a/ui/tests/unit/adapters/volume-test.js b/ui/tests/unit/adapters/volume-test.js index fdb3606c9..683716a4e 100644 --- a/ui/tests/unit/adapters/volume-test.js +++ b/ui/tests/unit/adapters/volume-test.js @@ -58,20 +58,6 @@ module('Unit | Adapter | Volume', function(hooks) { assert.deepEqual(pretender.handledRequests.mapBy('url'), ['/v1/volumes?type=csi']); }); - test('When a namespace is set in localStorage and the volume endpoint is queried, the namespace is in the query string', async function(assert) { - const { pretender } = this.server; - - window.localStorage.nomadActiveNamespace = 'some-namespace'; - await this.initializeUI(); - - this.subject().query(this.store, { modelName: 'volume' }, { type: 'csi' }, null, {}); - await settled(); - - assert.deepEqual(pretender.handledRequests.mapBy('url'), [ - '/v1/volumes?namespace=some-namespace&type=csi', - ]); - }); - test('When the volume has a namespace other than default, it is in the URL', async function(assert) { const { pretender } = this.server; const volumeName = 'csi/volume-1';