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.
This commit is contained in:
Michael Lange
2021-04-29 13:00:59 -07:00
committed by GitHub
parent 3aff3ffab5
commit 92391683da
72 changed files with 1179 additions and 995 deletions

View File

@@ -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,

View File

@@ -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');
});

View File

@@ -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');
}
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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');

View File

@@ -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;

View File

@@ -0,0 +1,13 @@
<div data-test-single-select-dropdown class="dropdown" ...attributes>
<PowerSelect
@options={{@options}}
@selected={{this.activeOption}}
@searchEnabled={{gt @options.length 10}}
@searchField="label"
@onChange={{action this.setSelection}}
@dropdownClass="dropdown-options"
as |option|>
<span class="ember-power-select-prefix">{{@label}}: </span>
<span class="dropdown-label" data-test-dropdown-option={{option.key}}>{{option.label}}</span>
</PowerSelect>
</div>

View File

@@ -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);
}
}

View File

@@ -1,13 +1,5 @@
import Controller from '@ember/controller';
export default class VolumesController extends Controller {
queryParams = [
{
volumeNamespace: 'namespace',
},
];
isForbidden = false;
volumeNamespace = 'default';
}

View File

@@ -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,
]);
}
}

View File

@@ -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();

View File

@@ -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 {}

View File

@@ -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') },
});
}
}

View File

@@ -0,0 +1,10 @@
import Controller from '@ember/controller';
export default class JobController extends Controller {
queryParams = [
{
jobNamespace: 'namespace',
},
];
jobNamespace = 'default';
}

View File

@@ -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 },
});
}
}

View File

@@ -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 },
});
}
}

View File

@@ -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 summarys 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;

View File

@@ -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

View File

@@ -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');
},

View File

@@ -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,

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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']);

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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')) {

View File

@@ -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');
});
}

View File

@@ -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

View File

@@ -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',
},
});
}
}

View File

@@ -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
);

View File

@@ -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;

View File

@@ -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),

View File

@@ -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);

View File

@@ -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: [] } };

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -1,4 +1,4 @@
{{#let (cannot "exec allocation") as |cannotExec|}}
{{#let (cannot "exec allocation" namespace=this.job.namespace) as |cannotExec|}}
<button
data-test-exec-button
type="button"
@@ -9,4 +9,5 @@
{{x-icon "console"}}
<span>Exec</span>
</button>
{{/let}}
{{/let}}

View File

@@ -23,27 +23,6 @@
</ul>
</div>
{{/if}}
{{#if this.system.shouldShowNamespaces}}
<ul class="menu-list">
<li>
<div class="menu-item is-wide" data-test-namespace-switcher-parent>
<PowerSelect
data-test-namespace-switcher
@options={{this.sortedNamespaces}}
@selected={{this.system.activeNamespace}}
@searchField="name"
@searchEnabled={{gt this.sortedNamespaces.length 10}}
@onChange={{action this.gotoJobsForNamespace}}
@tagName="div"
@class="namespace-switcher"
title={{this.system.activeNamespace.name}}
as |namespace|>
<span class="ember-power-select-prefix">Namespace: </span>{{namespace.name}}
</PowerSelect>
</div>
</li>
</ul>
{{/if}}
<p class="menu-label">
Workload
</p>
@@ -51,7 +30,6 @@
<li>
<LinkTo
@route="jobs"
@query={{hash jobNamespace=this.system.activeNamespace.id}}
@activeClass="is-active"
data-test-gutter-link="jobs">
Jobs
@@ -75,7 +53,6 @@
<li>
<LinkTo
@route="csi"
@query={{hash volumeNamespace=this.system.activeNamespace.id}}
@activeClass="is-active"
data-test-gutter-link="storage">
Storage <span class="tag is-small">Beta</span>

View File

@@ -9,7 +9,7 @@
<span data-test-job-stat="priority"><strong>Priority:</strong> {{this.job.priority}} </span>
<span data-test-job-stat="parent">
<strong>Parent:</strong>
<LinkTo @route="jobs.job" @model={{this.job.parent}} @query={{hash jobNamespace=this.job.parent.namespace.name}}>
<LinkTo @route="jobs.job" @model={{this.job.parent}} @query={{hash namespace=this.job.parent.namespace.name}}>
{{this.job.parent.name}}
</LinkTo>
</span>

View File

@@ -1,4 +1,7 @@
<td data-test-job-name><LinkTo @route="jobs.job" @model={{this.job.plainId}} class="is-primary">{{this.job.name}}</LinkTo></td>
<td data-test-job-name><LinkTo @route="jobs.job" @model={{this.job.plainId}} @query={{hash namespace=this.job.namespace.id}} class="is-primary">{{this.job.name}}</LinkTo></td>
{{#if this.system.shouldShowNamespaces}}
<td data-test-job-namespace>{{this.job.namespace.name}}</td>
{{/if}}
{{#if (eq @context "child")}}
<td data-test-job-submit-time>{{format-month-ts this.job.submitTime}}</td>
{{/if}}

View File

@@ -6,20 +6,35 @@
</ul>
</div>
<section class="section">
<div class="toolbar">
<div class="toolbar-item">
{{#if this.visibleVolumes.length}}
<SearchBox
data-test-volumes-search
@searchTerm={{mut this.searchTerm}}
@onChange={{action this.resetPagination}}
@placeholder="Search volumes..." />
{{/if}}
</div>
{{#if this.system.shouldShowNamespaces}}
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<div class="button-bar">
<SingleSelectDropdown
data-test-namespace-facet
@label="Namespace"
@options={{this.optionsNamespaces}}
@selection={{this.qpNamespace}}
@onSelect={{action (queue
(action this.cacheNamespace)
(action this.setFacetQueryParam "qpNamespace")
)}} />
</div>
</div>
{{/if}}
</div>
{{#if this.isForbidden}}
<ForbiddenMessage />
{{else}}
<div class="toolbar">
<div class="toolbar-item">
{{#if this.model.length}}
<SearchBox
data-test-volumes-search
@searchTerm={{mut this.searchTerm}}
@onChange={{action this.resetPagination}}
@placeholder="Search volumes..." />
{{/if}}
</div>
</div>
{{#if this.sortedVolumes}}
<ListPagination
@source={{this.sortedVolumes}}
@@ -32,6 +47,9 @@
@class="with-foot" as |t|>
<t.head>
<t.sort-by @prop="name">Name</t.sort-by>
{{#if this.system.shouldShowNamespaces}}
<t.sort-by @prop="namespace.name">Namespace</t.sort-by>
{{/if}}
<t.sort-by @prop="schedulable">Volume Health</t.sort-by>
<t.sort-by @prop="controllersHealthyProportion">Controller Health</t.sort-by>
<t.sort-by @prop="nodesHealthyProportion">Node Health</t.sort-by>
@@ -41,8 +59,17 @@
<t.body @key="model.name" as |row|>
<tr class="is-interactive" data-test-volume-row {{on "click" (action "gotoVolume" row.model)}}>
<td data-test-volume-name>
<LinkTo @route="csi.volumes.volume" @model={{row.model.plainId}} class="is-primary">{{row.model.name}}</LinkTo>
<LinkTo
@route="csi.volumes.volume"
@model={{row.model.plainId}}
@query={{hash volumeNamespace=row.model.namespace.name}}
class="is-primary">
{{row.model.name}}
</LinkTo>
</td>
{{#if this.system.shouldShowNamespaces}}
<td data-test-volume-namespace>{{row.model.namespace.name}}</td>
{{/if}}
<td data-test-volume-schedulable>{{if row.model.schedulable "Schedulable" "Unschedulable"}}</td>
<td data-test-volume-controller-health>
{{#if row.model.controllerRequired}}
@@ -80,10 +107,10 @@
</ListPagination>
{{else}}
<div data-test-empty-volumes-list class="empty-message">
{{#if (eq this.model.length 0)}}
{{#if (eq this.visibleVolumes.length 0)}}
<h3 data-test-empty-volumes-list-headline class="empty-message-headline">No Volumes</h3>
<p class="empty-message-body">
The cluster currently has no CSI Volumes.
This namespace currently has no CSI Volumes.
</p>
{{else if this.searchTerm}}
<h3 data-test-empty-volumes-list-headline class="empty-message-headline">No Matches</h3>

View File

@@ -1,77 +1,88 @@
{{page-title "Jobs"}}
<section class="section">
<div class="toolbar">
<div class="toolbar-item">
{{#if this.visibleJobs.length}}
<SearchBox
data-test-jobs-search
@searchTerm={{mut this.searchTerm}}
@onChange={{action this.resetPagination}}
@placeholder="Search jobs..." />
{{/if}}
</div>
{{#if (media "isMobile")}}
<div class="toolbar-item is-right-aligned">
{{#if (can "run job")}}
<LinkTo @route="jobs.run" data-test-run-job class="button is-primary">Run Job</LinkTo>
{{else}}
<button
data-test-run-job
class="button is-primary is-disabled tooltip is-right-aligned"
aria-label="You dont have permission to run jobs"
disabled
type="button"
>Run Job</button>
{{/if}}
</div>
{{/if}}
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<div class="button-bar">
{{#if this.system.shouldShowNamespaces}}
<SingleSelectDropdown
data-test-namespace-facet
@label="Namespace"
@options={{this.optionsNamespaces}}
@selection={{this.qpNamespace}}
@onSelect={{action (queue
(action this.cacheNamespace)
(action this.setFacetQueryParam "qpNamespace")
)}} />
{{/if}}
<MultiSelectDropdown
data-test-type-facet
@label="Type"
@options={{this.optionsType}}
@selection={{this.selectionType}}
@onSelect={{action this.setFacetQueryParam "qpType"}} />
<MultiSelectDropdown
data-test-status-facet
@label="Status"
@options={{this.optionsStatus}}
@selection={{this.selectionStatus}}
@onSelect={{action this.setFacetQueryParam "qpStatus"}} />
<MultiSelectDropdown
data-test-datacenter-facet
@label="Datacenter"
@options={{this.optionsDatacenter}}
@selection={{this.selectionDatacenter}}
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}} />
<MultiSelectDropdown
data-test-prefix-facet
@label="Prefix"
@options={{this.optionsPrefix}}
@selection={{this.selectionPrefix}}
@onSelect={{action this.setFacetQueryParam "qpPrefix"}} />
</div>
</div>
{{#if (not (media "isMobile"))}}
<div class="toolbar-item is-right-aligned">
{{#if (can "run job")}}
<LinkTo @route="jobs.run" data-test-run-job class="button is-primary">Run Job</LinkTo>
{{else}}
<button
data-test-run-job
class="button is-primary is-disabled tooltip is-right-aligned"
aria-label="You dont have permission to run jobs"
disabled
type="button"
>Run Job</button>
{{/if}}
</div>
{{/if}}
</div>
{{#if this.isForbidden}}
<ForbiddenMessage />
{{else}}
<div class="toolbar">
<div class="toolbar-item">
{{#if this.visibleJobs.length}}
<SearchBox
data-test-jobs-search
@searchTerm={{mut this.searchTerm}}
@onChange={{action this.resetPagination}}
@placeholder="Search jobs..." />
{{/if}}
</div>
{{#if (media "isMobile")}}
<div class="toolbar-item is-right-aligned">
{{#if (can "run job")}}
<LinkTo @route="jobs.run" data-test-run-job class="button is-primary">Run Job</LinkTo>
{{else}}
<button
data-test-run-job
class="button is-primary is-disabled tooltip is-right-aligned"
aria-label="You dont have permission to run jobs"
disabled
type="button"
>Run Job</button>
{{/if}}
</div>
{{/if}}
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<div class="button-bar">
<MultiSelectDropdown
data-test-type-facet
@label="Type"
@options={{this.optionsType}}
@selection={{this.selectionType}}
@onSelect={{action this.setFacetQueryParam "qpType"}} />
<MultiSelectDropdown
data-test-status-facet
@label="Status"
@options={{this.optionsStatus}}
@selection={{this.selectionStatus}}
@onSelect={{action this.setFacetQueryParam "qpStatus"}} />
<MultiSelectDropdown
data-test-datacenter-facet
@label="Datacenter"
@options={{this.optionsDatacenter}}
@selection={{this.selectionDatacenter}}
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}} />
<MultiSelectDropdown
data-test-prefix-facet
@label="Prefix"
@options={{this.optionsPrefix}}
@selection={{this.selectionPrefix}}
@onSelect={{action this.setFacetQueryParam "qpPrefix"}} />
</div>
</div>
{{#if (not (media "isMobile"))}}
<div class="toolbar-item is-right-aligned">
{{#if (can "run job")}}
<LinkTo @route="jobs.run" data-test-run-job class="button is-primary">Run Job</LinkTo>
{{else}}
<button
data-test-run-job
class="button is-primary is-disabled tooltip is-right-aligned"
aria-label="You dont have permission to run jobs"
disabled
type="button"
>Run Job</button>
{{/if}}
</div>
{{/if}}
</div>
{{#if this.sortedJobs}}
<ListPagination
@source={{this.sortedJobs}}
@@ -84,6 +95,9 @@
@class="with-foot" as |t|>
<t.head>
<t.sort-by @prop="name">Name</t.sort-by>
{{#if this.system.shouldShowNamespaces}}
<t.sort-by @prop="namespace.name">Namespace</t.sort-by>
{{/if}}
<t.sort-by @prop="status">Status</t.sort-by>
<t.sort-by @prop="type">Type</t.sort-by>
<t.sort-by @prop="priority">Priority</t.sort-by>

View File

@@ -1,18 +1,29 @@
<PageLayout>
<section class="section">
{{#if @model}}
{{#if this.summaries}}
<div class="toolbar collapse">
<div class="toolbar-item">
{{#if @model}}
{{#if this.summaries}}
<SearchBox
data-test-recommendation-summaries-search
@onChange={{this.syncActiveSummary}}
@searchTerm={{mut this.searchTerm}}
@placeholder="Search {{this.model.length}} {{pluralize "recommendation" this.model.length}}..." />
@placeholder="Search {{this.summaries.length}} {{pluralize "recommendation" this.summaries.length}}..." />
{{/if}}
</div>
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<div class="button-bar">
{{#if this.system.shouldShowNamespaces}}
<SingleSelectDropdown
data-test-namespace-facet
@label="Namespace"
@options={{this.optionsNamespaces}}
@selection={{this.qpNamespace}}
@onSelect={{action (queue
(action this.cacheNamespace)
(action this.setFacetQueryParam "qpNamespace")
)}} />
{{/if}}
<MultiSelectDropdown
data-test-type-facet
@label="Type"
@@ -40,20 +51,6 @@
</div>
</div>
</div>
{{#if this.system.shouldShowNamespaces}}
<div class="toolbar">
<div class="toolbar-item is-right-aligned is-mobile-full-width">
<Toggle
data-test-all-namespaces-toggle
@class="is-flex"
@isActive={{this.includeAllNamespaces}}
@onToggle={{this.toggleIncludeAllNamespaces}}
>
<div class="is-size-7 label">Include all namespaces</div>
</Toggle>
</div>
</div>
{{/if}}
{{#if this.filteredSummaries}}
{{outlet}}

View File

@@ -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 {

View File

@@ -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 }) {

View File

@@ -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 });

View File

@@ -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`
<h5 class="title is-5">Multi-Select Dropdown</h5>
<MultiSelectDropdown
@label="Example Dropdown"
@options={{this.options1}}
@selection={{this.selection1}}
@onSelect={{action (mut selection1)}} />
<p class="annotation">A wrapper around basic-dropdown for creating a list of checkboxes and tracking the state thereof.</p>
`,
context: {
options1,
selection1,
},
};
};
export let SingleSelect = () => ({
template: hbs`
<h5 class="title is-5">Single-Select Dropdown</h5>
<SingleSelectDropdown
@label="Single"
@options={{this.options1}}
@selection={{this.selection}}
@onSelect={{action (mut this.selection)}} />
`,
context: {
options1,
selection: 'option-2',
},
});
export let RightAligned = () => {
return {
template: hbs`
<h5 class="title is-5">Multi-Select Dropdown right-aligned</h5>
<div style="display:flex; justify-content:flex-end">
<MultiSelectDropdown
@label="Example right-aligned Dropdown"
@options={{this.options1}}
@selection={{this.selection1}}
@onSelect={{action (mut selection1)}} />
</div>
`,
context: {
options1,
selection1,
},
};
};
export let ManyOptionsMulti = () => {
return {
template: hbs`
<h5 class="title is-5">Multi-Select Dropdown with many options</h5>
<MultiSelectDropdown
@label="Lots of options in here"
@options={{this.optionsMany}}
@selection={{this.selectionMany}}
@onSelect={{action (mut this.selectionMany)}} />
<p class="annotation">
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.
</p>
`,
context: {
optionsMany: Array(100)
.fill(null)
.map((_, i) => ({ label: `Option ${i}`, key: `option-${i}` })),
selectionMany: [],
},
};
};
export let ManyOptionsSingle = () => {
return {
template: hbs`
<h5 class="title is-5">Single-Select Dropdown with many options</h5>
<SingleSelectDropdown
@label="Lots of options in here"
@options={{this.optionsMany}}
@selection={{this.selection}}
@onSelect={{action (mut this.selection)}} />
<p class="annotation">
Single select supports search at a certain option threshold via Ember Power Select.
</p>
`,
context: {
optionsMany: Array(100)
.fill(null)
.map((_, i) => ({ label: `Option ${i}`, key: `option-${i}` })),
selection: 'option-1',
},
};
};
export let Bar = () => {
return {
template: hbs`
<h5 class="title is-5">Multi-Select Dropdown bar</h5>
<div class="button-bar">
<MultiSelectDropdown
@label="Datacenter"
@options={{this.optionsDatacenter}}
@selection={{this.selectionDatacenter}}
@onSelect={{action (mut this.selectionDatacenter)}} />
<MultiSelectDropdown
@label="Type"
@options={{this.optionsType}}
@selection={{this.selectionType}}
@onSelect={{action (mut this.selectionType)}} />
<MultiSelectDropdown
@label="Status"
@options={{this.optionsStatus}}
@selection={{this.selectionStatus}}
@onSelect={{action (mut this.selectionStatus)}} />
</div>
<h5 class="title is-5">Single-Select Dropdown bar</h5>
<div class="button-bar">
<SingleSelectDropdown
@label="Datacenter"
@options={{this.optionsDatacenter}}
@selection={{this.selectionDatacenterSingle}}
@onSelect={{action (mut this.selectionDatacenterSingle)}} />
<SingleSelectDropdown
@label="Type"
@options={{this.optionsType}}
@selection={{this.selectionTypeSingle}}
@onSelect={{action (mut this.selectionTypeSingle)}} />
<SingleSelectDropdown
@label="Status"
@options={{this.optionsStatus}}
@selection={{this.selectionStatusSingle}}
@onSelect={{action (mut this.selectionStatusSingle)}} />
</div>
<h5 class="title is-5">Mixed Dropdown bar</h5>
<div class="button-bar">
<SingleSelectDropdown
@label="Datacenter"
@options={{this.optionsDatacenter}}
@selection={{this.selectionDatacenterSingle}}
@onSelect={{action (mut this.selectionDatacenterSingle)}} />
<MultiSelectDropdown
@label="Type"
@options={{this.optionsType}}
@selection={{this.selectionType}}
@onSelect={{action (mut this.selectionType)}} />
<MultiSelectDropdown
@label="Status"
@options={{this.optionsStatus}}
@selection={{this.selectionStatus}}
@onSelect={{action (mut this.selectionStatus)}} />
</div>
<p class="annotation">
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 <code>.button-bar</code> container.
</p>
`,
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',
},
};
};

View File

@@ -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`
<h5 class="title is-5">Multi-Select Dropdown</h5>
<MultiSelectDropdown
@label="Example Dropdown"
@options={{options1}}
@selection={{selection1}}
@onSelect={{action (mut selection1)}} />
<p class="annotation">A wrapper around basic-dropdown for creating a list of checkboxes and tracking the state thereof.</p>
`,
context: {
options1,
selection1,
},
};
};
export let RightAligned = () => {
return {
template: hbs`
<h5 class="title is-5">Multi-Select Dropdown right-aligned</h5>
<div style="display:flex; justify-content:flex-end">
<MultiSelectDropdown
@label="Example right-aligned Dropdown"
@options={{options1}}
@selection={{selection1}}
@onSelect={{action (mut selection1)}} />
</div>
`,
context: {
options1,
selection1,
},
};
};
export let ManyOptions = () => {
return {
template: hbs`
<h5 class="title is-5">Multi-Select Dropdown with many options</h5>
<MultiSelectDropdown
@label="Lots of options in here"
@options={{optionsMany}}
@selection={{selectionMany}}
@onSelect={{action (mut selectionMany)}} />
<p class="annotation">
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.
</p>
`,
context: {
optionsMany: Array(100)
.fill(null)
.map((_, i) => ({ label: `Option ${i}`, key: `option-${i}` })),
selectionMany: [],
},
};
};
export let Bar = () => {
return {
template: hbs`
<h5 class="title is-5">Multi-Select Dropdown bar</h5>
<div class="button-bar">
<MultiSelectDropdown
@label="Datacenter"
@options={{optionsDatacenter}}
@selection={{selectionDatacenter}}
@onSelect={{action (mut selectionDatacenter)}} />
<MultiSelectDropdown
@label="Type"
@options={{optionsType}}
@selection={{selectionType}}
@onSelect={{action (mut selectionType)}} />
<MultiSelectDropdown
@label="Status"
@options={{optionsStatus}}
@selection={{selectionStatus}}
@onSelect={{action (mut selectionStatus)}} />
</div>
<p class="annotation">
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 <code>.button-bar</code> container.
</p>
`,
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: [],
},
};
};

View File

@@ -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',

View File

@@ -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'
);
});

View File

@@ -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);
});
});

View File

@@ -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 jobs 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 doesnt show when there arent 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;

View File

@@ -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();

View File

@@ -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 its exchanged for a token', async function(assert) {
const { oneTimeSecret, secretId } = managementToken;

View File

@@ -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'));

View File

@@ -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'
);
});
}
});

View File

@@ -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;
});
};

View File

@@ -30,10 +30,10 @@ module('Integration | Component | multi-select dropdown', function(hooks) {
const commonTemplate = hbs`
<MultiSelectDropdown
@label={{label}}
@options={{options}}
@selection={{selection}}
@onSelect={{onSelect}} />
@label={{this.label}}
@options={{this.options}}
@selection={{this.selection}}
@onSelect={{this.onSelect}} />
`;
test('component is initially closed', async function(assert) {

View File

@@ -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`
<SingleSelectDropdown
@label={{this.label}}
@options={{this.options}}
@selection={{this.selection}}
@onSelect={{this.onSelect}} />
`;
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));
});
});

View File

@@ -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]'),
},
});

View File

@@ -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);
},
}),
});

View File

@@ -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]'),
},
});

View File

@@ -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: {

View File

@@ -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]', {

View File

@@ -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]'),
},
});

View File

@@ -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]'),
},
});

View File

@@ -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'
);
});
});

View File

@@ -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'
);
});
});

View File

@@ -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';