Merge pull request #11590 from hashicorp/e-ui/breadcrumbs-service

Refactor:  Breadcrumbs Service
This commit is contained in:
Jai
2022-01-05 17:46:48 -05:00
committed by GitHub
73 changed files with 1490 additions and 789 deletions

3
.changelog/11590.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: Add titles to breadcrumb labels in app navigation bar
```

View File

@@ -1,13 +0,0 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { reads } from '@ember/object/computed';
import { tagName } from '@ember-decorators/component';
import classic from 'ember-classic-decorator';
@classic
@tagName('')
export default class AppBreadcrumbs extends Component {
@service('breadcrumbs') breadcrumbsService;
@reads('breadcrumbsService.breadcrumbs') breadcrumbs;
}

View File

@@ -0,0 +1,27 @@
import { assert } from '@ember/debug';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import Component from '@glimmer/component';
export default class Breadcrumb extends Component {
@service breadcrumbs;
constructor() {
super(...arguments);
assert('Provide a valid breadcrumb argument', this.args.crumb);
this.register();
}
@action register() {
this.breadcrumbs.registerBreadcrumb(this);
}
@action deregister() {
this.breadcrumbs.deregisterBreadcrumb(this);
}
willDestroy() {
super.willDestroy();
this.deregister();
}
}

View File

@@ -0,0 +1 @@
{{yield this.crumbs}}

View File

@@ -0,0 +1,10 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
export default class Breadcrumbs extends Component {
@service breadcrumbs;
get crumbs() {
return this.breadcrumbs.crumbs;
}
}

View File

@@ -0,0 +1,16 @@
<li data-test-breadcrumb-default>
<LinkTo @params={{@crumb.args}} data-test-breadcrumb={{@crumb.args.firstObject}}>
{{#if @crumb.title}}
<dl>
<dt>
{{@crumb.title}}
</dt>
<dd>
{{@crumb.label}}
</dd>
</dl>
{{else}}
{{@crumb.label}}
{{/if}}
</LinkTo>
</li>

View File

@@ -0,0 +1,49 @@
<Trigger @onError={{action this.onError}} @do={{this.fetchParent}} as |trigger|>
{{did-insert trigger.fns.do}}
{{#if trigger.data.isBusy}}
<li>
<a href="#" aria-label="loading" data-test-breadcrumb="loading">
</a>
</li>
{{/if}}
{{#if trigger.data.isSuccess}}
{{#if trigger.data.result}}
<li>
<LinkTo
@route="jobs.job.index"
@model={{trigger.data.result.plainId}}
@query={{hash namespace=(or trigger.data.result.namespace.name "default")}}
data-test-breadcrumb={{"jobs.job.index"}}
>
<dl>
<dt>
Parent Job
</dt>
<dd>
{{trigger.data.result.trimmedName}}
</dd>
</dl>
</LinkTo>
</li>
{{/if}}
<li>
<LinkTo
@route="jobs.job.index"
@model={{this.job.plainId}}
@query={{hash namespace=(or this.job.namespace.name "default")}}
data-test-breadcrumb={{"jobs.job.index"}}
data-test-job-breadcrumb
>
<dl>
<dt>
{{if this.job.hasChildren "Parent Job" "Job"}}
</dt>
<dd>
{{this.job.trimmedName}}
</dd>
</dl>
</LinkTo>
</li>
{{/if}}
</Trigger>

View File

@@ -0,0 +1,22 @@
import { assert } from '@ember/debug';
import { action } from '@ember/object';
import Component from '@glimmer/component';
export default class BreadcrumbsJob extends Component {
get job() {
return this.args.crumb.job;
}
@action
onError(err) {
assert(`Error: ${err.message}`);
}
@action
fetchParent() {
const hasParent = !!this.job.belongsTo('parent').id();
if (hasParent) {
return this.job.get('parent');
}
}
}

View File

@@ -0,0 +1 @@
{{yield (hash data=this.data fns=this.fns)}}

View File

@@ -0,0 +1,68 @@
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
const noOp = () => undefined;
export default class Trigger extends Component {
@tracked error = null;
@tracked result = null;
get isBusy() {
return this.triggerTask.isRunning;
}
get isIdle() {
return this.triggerTask.isIdle;
}
get isSuccess() {
return this.triggerTask.last?.isSuccessful;
}
get isError() {
return !!this.error;
}
get fns() {
return {
do: this.onTrigger,
};
}
get onError() {
return this.args.onError ?? noOp;
}
get onSuccess() {
return this.args.onSuccess ?? noOp;
}
get data() {
const { isBusy, isIdle, isSuccess, isError, result } = this;
return { isBusy, isIdle, isSuccess, isError, result };
}
_reset() {
this.result = null;
this.error = null;
}
@task(function*() {
this._reset();
try {
this.result = yield this.args.do();
this.onSuccess(this.result);
} catch (e) {
this.error = { Error: e };
this.onError(this.error);
}
})
triggerTask;
@action
onTrigger() {
this.triggerTask.perform();
}
}

View File

@@ -0,0 +1,47 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { qpBuilder } from 'nomad-ui/utils/classes/query-params';
export default class AllocationsAllocationController extends Controller {
@service store;
get allocation() {
return this.model;
}
get job() {
const allocation = this.model;
const jobId = allocation.belongsTo('job').id();
const job = this.store.peekRecord('job', jobId);
return job;
}
get jobNamespace() {
const jobNamespaceId = this.job.belongsTo('namespace').id();
return jobNamespaceId || 'default';
}
// Allocation breadcrumbs extend from job / task group breadcrumbs
// even though the route structure does not.
get breadcrumbs() {
const { allocation, job, jobNamespace } = this;
const jobQueryParams = qpBuilder({
jobNamespace,
});
return [
{ label: 'Jobs', args: ['jobs.index', jobQueryParams] },
{ type: 'job', job: job },
{
title: 'Task Group',
label: allocation.taskGroupName,
args: ['jobs.job.task-group', job.plainId, allocation.taskGroupName, jobQueryParams],
},
{
title: 'Allocation',
label: allocation.shortId,
args: ['allocations.allocation', allocation],
},
];
}
}

View File

@@ -0,0 +1,15 @@
import Controller from '@ember/controller';
export default class AllocationsAllocationTaskController extends Controller {
get task() {
return this.model;
}
get breadcrumb() {
return {
title: 'Task',
label: this.task.get('name'),
args: ['allocations.allocation.task', this.task.get('allocation'), this.task],
};
}
}

View File

@@ -0,0 +1,15 @@
import Controller from '@ember/controller';
export default class ClientsClientController extends Controller {
get client() {
return this.model;
}
get breadcrumb() {
return {
title: 'Client',
label: this.client.get('shortId'),
args: ['clients.client', this.client.get('id')],
};
}
}

View File

@@ -0,0 +1,21 @@
import Controller from '@ember/controller';
export default class CsiPluginsPluginController extends Controller {
get plugin() {
return this.model;
}
get breadcrumbs() {
const { plainId } = this.plugin;
return [
{
label: 'Plugins',
args: ['csi.plugins'],
},
{
label: plainId,
args: ['csi.plugins.plugin', plainId],
},
];
}
}

View File

@@ -1,6 +1,7 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action, computed } from '@ember/object';
import { qpBuilder } from 'nomad-ui/utils/classes/query-params';
export default class VolumeController extends Controller {
// Used in the template
@@ -13,6 +14,31 @@ export default class VolumeController extends Controller {
];
volumeNamespace = 'default';
get volume() {
return this.model;
}
get breadcrumbs() {
const volume = this.volume;
return [
{
label: 'Volumes',
args: [
'csi.volumes',
qpBuilder({ volumeNamespace: volume.get('namespace.name') || 'default' }),
],
},
{
label: volume.name,
args: [
'csi.volumes.volume',
volume.plainId,
qpBuilder({ volumeNamespace: volume.get('namespace.name') || 'default' }),
],
},
];
}
@computed('model.readAllocations.@each.modifyIndex')
get sortedReadAllocations() {
return this.model.readAllocations.sortBy('modifyIndex').reverse();

View File

@@ -1,3 +1,4 @@
import Controller from '@ember/controller';
// The WithNamespaceResetting Mixin uses Controller Injection and requires us to keep this controller around
export default class JobsController extends Controller {}

View File

@@ -7,4 +7,8 @@ export default class JobController extends Controller {
},
];
jobNamespace = 'default';
get job() {
return this.model;
}
}

View File

@@ -0,0 +1,4 @@
import Controller from '@ember/controller';
// This may be safe to remove but we can't be sure, some route may try access this directly using this.controllerFor
export default class JobsJobDispatchController extends Controller {}

View File

@@ -5,6 +5,7 @@ import Controller from '@ember/controller';
import { action, computed, get } from '@ember/object';
import { scheduleOnce } from '@ember/runloop';
import intersection from 'lodash.intersection';
import { qpBuilder } from 'nomad-ui/utils/classes/query-params';
import Sortable from 'nomad-ui/mixins/sortable';
import Searchable from 'nomad-ui/mixins/searchable';
import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
@@ -13,10 +14,10 @@ import classic from 'ember-classic-decorator';
@classic
export default class TaskGroupController extends Controller.extend(
Sortable,
Searchable,
WithNamespaceResetting
) {
Sortable,
Searchable,
WithNamespaceResetting
) {
@service userSettings;
@service can;
@@ -141,4 +142,22 @@ export default class TaskGroupController extends Controller.extend(
setFacetQueryParam(queryParam, selection) {
this.set(queryParam, serialize(selection));
}
get taskGroup() {
return this.model;
}
get breadcrumb() {
const { job, name } = this.taskGroup;
return {
title: 'Task Group',
label: name,
args: [
'jobs.job.task-group',
job,
name,
qpBuilder({ jobNamespace: job.get('namespace.name') || 'default' }),
],
};
}
}

View File

@@ -9,4 +9,16 @@ export default class OptimizeSummaryController extends Controller {
jobNamespace: 'namespace',
},
];
get summary() {
return this.model;
}
get breadcrumb() {
const { slug } = this.summary;
return {
label: slug.replace('/', ' / '),
args: ['optimize.summary', slug],
};
}
}

View File

@@ -0,0 +1,7 @@
import Controller from '@ember/controller';
export default class ServersServerController extends Controller {
get server() {
return this.model;
}
}

View File

@@ -1,49 +1,28 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { collect } from '@ember/object/computed';
import { watchRecord } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
import notifyError from 'nomad-ui/utils/notify-error';
import { qpBuilder } from 'nomad-ui/utils/classes/query-params';
import { jobCrumbs } from 'nomad-ui/utils/breadcrumb-utils';
export default class AllocationRoute extends Route.extend(WithWatchers) {
@service store;
startWatchers(controller, model) {
if (model) {
controller.set('watcher', this.watch.perform(model));
}
}
// Allocation breadcrumbs extend from job / task group breadcrumbs
// even though the route structure does not.
breadcrumbs(model) {
const jobQueryParams = qpBuilder({
jobNamespace: model.get('job.namespace.name') || 'default',
});
return [
{ label: 'Jobs', args: ['jobs.index', jobQueryParams] },
...jobCrumbs(model.get('job')),
{
label: model.get('taskGroupName'),
args: [
'jobs.job.task-group',
model.get('job.plainId'),
model.get('taskGroupName'),
jobQueryParams,
],
},
{
label: model.get('shortId'),
args: ['allocations.allocation', model],
},
];
}
model() {
// Preload the job for the allocation since it's required for the breadcrumb trail
return super
.model(...arguments)
.then(allocation => allocation.get('job').then(() => allocation))
.then(allocation =>
allocation
.get('job')
.then(() => this.store.findAll('namespace')) // namespaces belong to a job and are an asynchronous relationship so we can peak them later on
.then(() => allocation)
)
.catch(notifyError(this));
}

View File

@@ -5,16 +5,6 @@ import EmberError from '@ember/error';
export default class TaskRoute extends Route {
@service store;
breadcrumbs(model) {
if (!model) return [];
return [
{
label: model.get('name'),
args: ['allocations.allocation.task', model.get('allocation'), model],
},
];
}
model({ name }) {
const allocation = this.modelFor('allocations.allocation');

View File

@@ -10,13 +10,6 @@ export default class ClientsRoute extends Route.extend(WithForbiddenState) {
@service store;
@service system;
breadcrumbs = [
{
label: 'Clients',
args: ['clients.index'],
},
];
beforeModel() {
return this.get('system.leader');
}

View File

@@ -9,16 +9,6 @@ export default class ClientRoute extends Route {
return super.model(...arguments).catch(notifyError(this));
}
breadcrumbs(model) {
if (!model) return [];
return [
{
label: model.get('shortId'),
args: ['clients.client', model.get('id')],
},
];
}
afterModel(model) {
if (model && model.get('isPartial')) {
return model.reload().then(node => node.get('allocations'));

View File

@@ -6,13 +6,6 @@ import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
export default class PluginsRoute extends Route.extend(WithForbiddenState) {
@service store;
breadcrumbs = [
{
label: 'Storage',
args: ['csi.index'],
},
];
model() {
return this.store.query('plugin', { type: 'csi' }).catch(notifyForbidden(this));
}

View File

@@ -6,17 +6,6 @@ export default class PluginRoute extends Route {
@service store;
@service system;
breadcrumbs = plugin => [
{
label: 'Plugins',
args: ['csi.plugins'],
},
{
label: plugin.plainId,
args: ['csi.plugins.plugin', plugin.plainId],
},
];
serialize(model) {
return { plugin_name: model.get('plainId') };
}

View File

@@ -6,11 +6,4 @@ import classic from 'ember-classic-decorator';
export default class VolumesRoute extends Route.extend() {
@service system;
@service store;
breadcrumbs = [
{
label: 'Storage',
args: ['csi.index'],
},
];
}

View File

@@ -3,7 +3,6 @@ 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';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
import classic from 'ember-classic-decorator';
@@ -13,24 +12,6 @@ export default class VolumeRoute extends Route.extend(WithWatchers) {
@service store;
@service system;
breadcrumbs = volume => [
{
label: 'Volumes',
args: [
'csi.volumes',
qpBuilder({ volumeNamespace: volume.get('namespace.name') || 'default' }),
],
},
{
label: volume.name,
args: [
'csi.volumes.volume',
volume.plainId,
qpBuilder({ volumeNamespace: volume.get('namespace.name') || 'default' }),
],
},
];
startWatchers(controller, model) {
if (!model) return;

View File

@@ -1,12 +0,0 @@
import Route from '@ember/routing/route';
import classic from 'ember-classic-decorator';
@classic
export default class JobsRoute extends Route.extend() {
breadcrumbs = [
{
label: 'Jobs',
args: ['jobs.index'],
},
];
}

View File

@@ -2,7 +2,6 @@ import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import RSVP from 'rsvp';
import notifyError from 'nomad-ui/utils/notify-error';
import { jobCrumbs } from 'nomad-ui/utils/breadcrumb-utils';
import classic from 'ember-classic-decorator';
@classic
@@ -11,8 +10,6 @@ export default class JobRoute extends Route {
@service store;
@service token;
breadcrumbs = jobCrumbs;
serialize(model) {
return { job_name: model.get('plainId') };
}

View File

@@ -4,13 +4,6 @@ import { inject as service } from '@ember/service';
export default class DispatchRoute extends Route {
@service can;
breadcrumbs = [
{
label: 'Dispatch',
args: ['jobs.job.dispatch'],
},
];
beforeModel() {
const job = this.modelFor('jobs.job');
const namespace = job.namespace.get('name');

View File

@@ -4,25 +4,9 @@ import EmberError from '@ember/error';
import { resolve, all } from 'rsvp';
import { watchRecord, watchRelationship } from 'nomad-ui/utils/properties/watch';
import WithWatchers from 'nomad-ui/mixins/with-watchers';
import { qpBuilder } from 'nomad-ui/utils/classes/query-params';
import notifyError from 'nomad-ui/utils/notify-error';
export default class TaskGroupRoute extends Route.extend(WithWatchers) {
breadcrumbs(model) {
if (!model) return [];
return [
{
label: model.get('name'),
args: [
'jobs.job.task-group',
model.get('job'),
model.get('name'),
qpBuilder({ jobNamespace: model.get('job.namespace.name') || 'default' }),
],
},
];
}
model({ name }) {
const job = this.modelFor('jobs.job');

View File

@@ -8,13 +8,6 @@ export default class RunRoute extends Route {
@service store;
@service system;
breadcrumbs = [
{
label: 'Run',
args: ['jobs.run'],
},
];
beforeModel(transition) {
if (this.can.cannot('run job', null, { namespace: transition.to.queryParams.namespace })) {
this.transitionTo('jobs');

View File

@@ -9,13 +9,6 @@ import RSVP from 'rsvp';
export default class OptimizeRoute extends Route {
@service can;
breadcrumbs = [
{
label: 'Recommendations',
args: ['optimize'],
},
];
beforeModel() {
if (this.can.cannot('accept recommendation')) {
this.transitionTo('jobs');

View File

@@ -2,17 +2,6 @@ import Route from '@ember/routing/route';
import notifyError from 'nomad-ui/utils/notify-error';
export default class OptimizeSummaryRoute extends Route {
breadcrumbs(model) {
if (!model) return [];
return [
{
label: model.slug.replace('/', ' / '),
args: ['optimize.summary', model.slug],
},
];
}
async model({ jobNamespace, slug }) {
const model = this.modelFor('optimize').summaries.find(
summary => summary.slug === slug && summary.jobNamespace === jobNamespace

View File

@@ -10,13 +10,6 @@ export default class ServersRoute extends Route.extend(WithForbiddenState) {
@service store;
@service system;
breadcrumbs = [
{
label: 'Servers',
args: ['servers.index'],
},
];
beforeModel() {
return this.get('system.leader');
}

View File

@@ -1,14 +1,4 @@
import Route from '@ember/routing/route';
import WithModelErrorHandling from 'nomad-ui/mixins/with-model-error-handling';
export default class ServerRoute extends Route.extend(WithModelErrorHandling) {
breadcrumbs(model) {
if (!model) return [];
return [
{
label: model.name,
args: ['servers.server', model.id],
},
];
}
}
export default class ServerRoute extends Route.extend(WithModelErrorHandling) {}

View File

@@ -10,13 +10,6 @@ export default class TopologyRoute extends Route.extend(WithForbiddenState) {
@service store;
@service system;
breadcrumbs = [
{
label: 'Topology',
args: ['topology'],
},
];
model() {
return RSVP.hash({
jobs: this.store.findAll('job'),

View File

@@ -1,45 +1,20 @@
import { getOwner } from '@ember/application';
import Service, { inject as service } from '@ember/service';
import { computed } from '@ember/object';
import classic from 'ember-classic-decorator';
import Service from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { schedule } from '@ember/runloop';
@classic
export default class BreadcrumbsService extends Service {
@service router;
export default class BucketService extends Service {
@tracked crumbs = [];
// currentURL is only used to listen to all transitions.
// currentRouteName has all information necessary to compute breadcrumbs,
// but it doesn't change when a transition to the same route with a different
// model occurs.
@computed('router.{currentURL,currentRouteName}')
get breadcrumbs() {
const owner = getOwner(this);
const allRoutes = (this.get('router.currentRouteName') || '')
.split('.')
.without('')
.map((segment, index, allSegments) => allSegments.slice(0, index + 1).join('.'));
let crumbs = [];
allRoutes.forEach(routeName => {
const route = owner.lookup(`route:${routeName}`);
// Routes can reset the breadcrumb trail to start anew even
// if the route is deeply nested.
if (route.resetBreadcrumbs) {
crumbs = [];
}
// Breadcrumbs are either an array of static crumbs
// or a function that returns breadcrumbs given the current
// model for the route's controller.
let breadcrumbs = route.breadcrumbs || [];
if (typeof breadcrumbs === 'function') {
breadcrumbs = breadcrumbs(route.get('controller.model')) || [];
}
crumbs.push(...breadcrumbs);
@action registerBreadcrumb(crumb) {
schedule('actions', this, () => {
this.crumbs = [...this.crumbs, crumb];
});
}
return crumbs;
@action deregisterBreadcrumb(crumb) {
const newCrumbs = this.crumbs.filter(c => c !== crumb);
this.crumbs = newCrumbs;
}
}

View File

@@ -11,7 +11,24 @@
}
}
li.is-active a {
ul {
align-items: center;
}
li::before {
font-size: x-large;
}
li:last-child a.active {
opacity: 1;
}
dl dd {
margin: -4px 0px;
font-size: medium;
}
dl dt {
font-size: small;
}
}

View File

@@ -1,5 +1,6 @@
.navbar {
display: flex;
align-items: center;
&.is-primary {
background: linear-gradient(to right, $nomad-green-darker, $nomad-green-dark);

View File

@@ -1 +1,4 @@
{{outlet}}
{{#each this.breadcrumbs as |crumb|}}
<Breadcrumb @crumb={{crumb}} />
{{/each}}
{{outlet}}

View File

@@ -1 +1 @@
{{outlet}}
<Breadcrumb @crumb={{this.breadcrumb}} />{{outlet}}

View File

@@ -1,3 +1,4 @@
<Breadcrumb @crumb={{hash label="Clients" args=(array "clients.index")}} />
<PageLayout>
{{outlet}}
</PageLayout>
</PageLayout>

View File

@@ -1 +1 @@
{{outlet}}
<Breadcrumb @crumb={{this.breadcrumb}} />{{outlet}}

View File

@@ -1,13 +1,7 @@
{{#each this.breadcrumbs as |breadcrumb index|}}
<li class="{{if (eq (inc index) this.breadcrumbs.length) "is-active"}}">
{{#if breadcrumb.isPending}}
<a href="#" aria-label="loading" data-test-breadcrumb="loading">&hellip;</a>
{{else}}
<LinkTo
@params={{breadcrumb.args}}
data-test-breadcrumb={{breadcrumb.args.firstObject}}>
{{breadcrumb.label}}
</LinkTo>
{{/if}}
</li>
{{/each}}
<Breadcrumbs as |breadcrumbs|>
{{#each breadcrumbs as |crumb|}}
{{#let crumb.args.crumb as |c|}}
{{component (concat "breadcrumbs/" (or c.type "default")) crumb=c}}
{{/let}}
{{/each}}
</Breadcrumbs>

View File

@@ -1 +1 @@
{{outlet}}
<Breadcrumb @crumb={{hash label="Storage" args=(array "csi.index")}} />{{outlet}}

View File

@@ -1 +1,4 @@
{{outlet}}
{{#each this.breadcrumbs as |crumb|}}
<Breadcrumb @crumb={{crumb}} />
{{/each}}
{{outlet}}

View File

@@ -1 +1 @@
{{outlet}}
<Breadcrumb @crumb={{hash label="Storage" args=(array "csi.index")}} />{{outlet}}

View File

@@ -1,107 +1,160 @@
{{#each this.breadcrumbs as |crumb|}}
<Breadcrumb @crumb={{crumb}} />
{{/each}}
{{page-title "CSI Volume " this.model.name}}
<section class="section with-headspace">
<h1 class="title" data-test-title>{{this.model.name}}</h1>
<h1 class="title" data-test-title>
{{this.model.name}}
</h1>
<div class="boxed-section is-small">
<div class="boxed-section-body inline-definitions">
<span class="label">Volume Details</span>
<span class="label">
Volume Details
</span>
<span class="pair" data-test-volume-health>
<span class="term">Health</span>
<span class="term">
Health
</span>
{{if this.model.schedulable "Schedulable" "Unschedulable"}}
</span>
<span class="pair" data-test-volume-provider>
<span class="term">Provider</span>
<span class="term">
Provider
</span>
{{this.model.provider}}
</span>
<span class="pair" data-test-volume-external-id>
<span class="term">External ID</span>
<span class="term">
External ID
</span>
{{this.model.externalId}}
</span>
{{#if this.system.shouldShowNamespaces}}
<span class="pair" data-test-volume-namespace>
<span class="term">Namespace</span>
<span class="term">
Namespace
</span>
{{this.model.namespace.name}}
</span>
{{/if}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Write Allocations
</div>
<div class="boxed-section-body {{if this.model.writeAllocations.length "is-full-bleed"}}">
{{#if this.model.writeAllocations.length}}
<ListTable
@source={{this.sortedWriteAllocations}}
@class="with-foot" as |t|>
<ListTable @source={{this.sortedWriteAllocations}} @class="with-foot" as |t|>
<t.head>
<th class="is-narrow"></th>
<th>ID</th>
<th>Created</th>
<th>Modified</th>
<th>Status</th>
<th>Client</th>
<th>Job</th>
<th>Version</th>
<th>CPU</th>
<th>Memory</th>
<th>
ID
</th>
<th>
Created
</th>
<th>
Modified
</th>
<th>
Status
</th>
<th>
Client
</th>
<th>
Job
</th>
<th>
Version
</th>
<th>
CPU
</th>
<th>
Memory
</th>
</t.head>
<t.body as |row|>
<AllocationRow
@data-test-write-allocation={{row.model.id}}
@allocation={{row.model}}
@context="volume"
@onClick={{action "gotoAllocation" row.model}} />
@onClick={{action "gotoAllocation" row.model}}
/>
</t.body>
</ListTable>
{{else}}
<div class="empty-message" data-test-empty-write-allocations>
<h3 class="empty-message-headline" data-test-empty-write-allocations-headline>No Write Allocations</h3>
<p class="empty-message-body" data-test-empty-write-allocations-message>No allocations are depending on this volume for read/write access.</p>
<h3 class="empty-message-headline" data-test-empty-write-allocations-headline>
No Write Allocations
</h3>
<p class="empty-message-body" data-test-empty-write-allocations-message>
No allocations are depending on this volume for read/write access.
</p>
</div>
{{/if}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Read Allocations
</div>
<div class="boxed-section-body {{if this.model.readAllocations.length "is-full-bleed"}}">
{{#if this.model.readAllocations.length}}
<ListTable
@source={{this.sortedReadAllocations}}
@class="with-foot" as |t|>
<ListTable @source={{this.sortedReadAllocations}} @class="with-foot" as |t|>
<t.head>
<th class="is-narrow"></th>
<th>ID</th>
<th>Modified</th>
<th>Created</th>
<th>Status</th>
<th>Client</th>
<th>Job</th>
<th>Version</th>
<th>CPU</th>
<th>Memory</th>
<th>
ID
</th>
<th>
Modified
</th>
<th>
Created
</th>
<th>
Status
</th>
<th>
Client
</th>
<th>
Job
</th>
<th>
Version
</th>
<th>
CPU
</th>
<th>
Memory
</th>
</t.head>
<t.body as |row|>
<AllocationRow
@data-test-read-allocation={{row.model.id}}
@allocation={{row.model}}
@context="volume"
@onClick={{action "gotoAllocation" row.model}} />
@onClick={{action "gotoAllocation" row.model}}
/>
</t.body>
</ListTable>
{{else}}
<div class="empty-message" data-test-empty-read-allocations>
<h3 class="empty-message-headline" data-test-empty-read-allocations-headline>No Read Allocations</h3>
<p class="empty-message-body" data-test-empty-read-allocations-message>No allocations are depending on this volume for read-only access.</p>
<h3 class="empty-message-headline" data-test-empty-read-allocations-headline>
No Read Allocations
</h3>
<p class="empty-message-body" data-test-empty-read-allocations-message>
No allocations are depending on this volume for read-only access.
</p>
</div>
{{/if}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
Constraints
@@ -109,20 +162,32 @@
<div class="boxed-section-body is-full-bleed">
<table class="table">
<thead>
<th>Setting</th>
<th>Value</th>
<th>
Setting
</th>
<th>
Value
</th>
</thead>
<tbody>
<tr>
<td>Access Mode</td>
<td data-test-access-mode>{{this.model.accessMode}}</td>
<td>
Access Mode
</td>
<td data-test-access-mode>
{{this.model.accessMode}}
</td>
</tr>
<tr>
<td>Attachment Mode</td>
<td data-test-attachment-mode>{{this.model.attachmentMode}}</td>
<td>
Attachment Mode
</td>
<td data-test-attachment-mode>
{{this.model.attachmentMode}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</section>

View File

@@ -1,3 +1,4 @@
<Breadcrumb @crumb={{hash label="Jobs" args=(array "jobs.index")}} />
<PageLayout>
{{outlet}}
</PageLayout>
</PageLayout>

View File

@@ -1 +1 @@
{{outlet}}
<Breadcrumb @crumb={{hash type="job" job=this.job}} />{{outlet}}

View File

@@ -1,5 +1,6 @@
<Breadcrumb @crumb={{hash label="Dispatch" args=(array "jobs.job.dispatch")}} />
{{page-title "Dispatch new " this.model.name}}
<JobSubnav @job={{this.model}} />
<section class="section">
<JobDispatch @job={{this.model}}/>
</section>
<JobDispatch @job={{this.model}} />
</section>

View File

@@ -1,64 +1,117 @@
<Breadcrumb @crumb={{this.breadcrumb}} />
{{page-title "Task group " this.model.name " - Job " this.model.job.name}}
<div class="tabs is-subnav">
<ul>
<li><LinkTo @route="jobs.job.task-group" @models={{array this.model.job this.model}} @activeClass="is-active">Overview</LinkTo></li>
<li>
<LinkTo
@route="jobs.job.task-group"
@models={{array this.model.job this.model}}
@activeClass="is-active"
>
Overview
</LinkTo>
</li>
</ul>
</div>
<section class="section">
<h1 class="title with-flex">
<span>{{this.model.name}}</span>
<span>
{{this.model.name}}
</span>
<div>
<Exec::OpenButton @job={{this.model.job}} @taskGroup={{this.model}} />
{{#if this.model.scaling}}
<StepperInput
data-test-task-group-count-stepper
aria-label={{this.tooltipText}}
@min={{this.model.scaling.min}}
@max={{this.model.scaling.max}}
@value={{this.model.count}}
@class="is-primary is-small"
@disabled={{or this.model.job.runningDeployment (cannot "scale job" namespace=this.model.job.namespace.name)}}
@onChange={{action "scaleTaskGroup"}}>
Count
</StepperInput>
<StepperInput
data-test-task-group-count-stepper
aria-label={{this.tooltipText}}
@min={{this.model.scaling.min}}
@max={{this.model.scaling.max}}
@value={{this.model.count}}
@class="is-primary is-small"
@disabled={{or
this.model.job.runningDeployment
(cannot "scale job" namespace=this.model.job.namespace.name)
}}
@onChange={{action "scaleTaskGroup"}}
>
Count
</StepperInput>
{{/if}}
</div>
</h1>
<div class="boxed-section is-small">
<div class="boxed-section-body inline-definitions">
<span class="label">Task Group Details</span>
<span class="pair" data-test-task-group-tasks><span class="term"># Tasks</span> {{this.model.tasks.length}}</span>
<span class="pair" data-test-task-group-cpu><span class="term">Reserved CPU</span> {{format-scheduled-hertz this.model.reservedCPU}}</span>
<span class="label">
Task Group Details
</span>
<span class="pair" data-test-task-group-tasks>
<span class="term">
# Tasks
</span>
{{this.model.tasks.length}}
</span>
<span class="pair" data-test-task-group-cpu>
<span class="term">
Reserved CPU
</span>
{{format-scheduled-hertz this.model.reservedCPU}}
</span>
<span class="pair" data-test-task-group-mem>
<span class="term">Reserved Memory</span>
<span class="term">
Reserved Memory
</span>
{{format-scheduled-bytes this.model.reservedMemory start="MiB"}}
{{#if (gt this.model.reservedMemoryMax this.model.reservedMemory)}}
({{format-scheduled-bytes this.model.reservedMemoryMax start="MiB"}} Max)
({{format-scheduled-bytes this.model.reservedMemoryMax start="MiB"}}Max)
{{/if}}
</span>
<span class="pair" data-test-task-group-disk><span class="term">Reserved Disk</span> {{format-scheduled-bytes this.model.reservedEphemeralDisk start="MiB"}}</span>
{{#if this.model.scaling}}
<span class="pair" data-test-task-group-min><span class="term">Count Range</span>
{{this.model.scaling.min}} to {{this.model.scaling.max}}
<span class="pair" data-test-task-group-disk>
<span class="term">
Reserved Disk
</span>
<span class="pair" data-test-task-group-max><span class="term">Scaling Policy?</span>
{{format-scheduled-bytes this.model.reservedEphemeralDisk start="MiB"}}
</span>
{{#if this.model.scaling}}
<span class="pair" data-test-task-group-min>
<span class="term">
Count Range
</span>
{{this.model.scaling.min}}
to
{{this.model.scaling.max}}
</span>
<span class="pair" data-test-task-group-max>
<span class="term">
Scaling Policy?
</span>
{{if this.model.scaling.policy "Yes" "No"}}
</span>
{{/if}}
</div>
</div>
<div class="boxed-section">
<div class="boxed-section-head">
<div>Allocation Status <span class="badge is-white">{{this.allocations.length}}</span></div>
<div>
Allocation Status
<span class="badge is-white">
{{this.allocations.length}}
</span>
</div>
</div>
<div class="boxed-section-body">
<AllocationStatusBar @allocationContainer={{this.model.summary}} @class="split-view" as |chart|>
<AllocationStatusBar
@allocationContainer={{this.model.summary}}
@class="split-view" as |chart|
>
<ol class="legend">
{{#each chart.data as |datum index|}}
<li class="{{datum.className}} {{if (eq datum.label chart.activeDatum.label) "is-active"}} {{if (eq datum.value 0) "is-empty"}}">
<li
class="{{datum.className}}
{{if (eq datum.label chart.activeDatum.label) "is-active"}}
{{if (eq datum.value 0) "is-empty"}}"
>
<JobPage::Parts::SummaryLegendItem @datum={{datum}} @index={{index}} />
</li>
{{/each}}
@@ -99,62 +152,102 @@
@source={{this.sortedAllocations}}
@size={{this.pageSize}}
@page={{this.currentPage}}
@class="allocations" as |p|>
@class="allocations" as |p|
>
<ListTable
@source={{p.list}}
@sortProperty={{this.sortProperty}}
@sortDescending={{this.sortDescending}}
@class="with-foot" as |t|>
@class="with-foot" as |t|
>
<t.head>
<th class="is-narrow"></th>
<t.sort-by @prop="shortId">ID</t.sort-by>
<t.sort-by @prop="createIndex" @title="Create Index">Created</t.sort-by>
<t.sort-by @prop="modifyIndex" @title="Modify Index">Modified</t.sort-by>
<t.sort-by @prop="statusIndex">Status</t.sort-by>
<t.sort-by @prop="jobVersion">Version</t.sort-by>
<t.sort-by @prop="node.shortId">Client</t.sort-by>
<th>Volume</th>
<th>CPU</th>
<th>Memory</th>
<t.sort-by @prop="shortId">
ID
</t.sort-by>
<t.sort-by @prop="createIndex" @title="Create Index">
Created
</t.sort-by>
<t.sort-by @prop="modifyIndex" @title="Modify Index">
Modified
</t.sort-by>
<t.sort-by @prop="statusIndex">
Status
</t.sort-by>
<t.sort-by @prop="jobVersion">
Version
</t.sort-by>
<t.sort-by @prop="node.shortId">
Client
</t.sort-by>
<th>
Volume
</th>
<th>
CPU
</th>
<th>
Memory
</th>
</t.head>
<t.body @key="model.id" as |row|>
<AllocationRow @data-test-allocation={{row.model.id}} @allocation={{row.model}} @context="taskGroup" @onClick={{action "gotoAllocation" row.model}} />
<AllocationRow
@data-test-allocation={{row.model.id}}
@allocation={{row.model}}
@context="taskGroup"
@onClick={{action "gotoAllocation" row.model}}
/>
</t.body>
</ListTable>
<div class="table-foot">
<PageSizeSelect @onChange={{action this.resetPagination}} />
<nav class="pagination">
<div class="pagination-numbers">
{{p.startsAt}}&ndash;{{p.endsAt}} of {{this.sortedAllocations.length}}
{{p.startsAt}}
{{p.endsAt}}
of
{{this.sortedAllocations.length}}
</div>
<p.prev @class="pagination-previous">{{x-icon "chevron-left"}}</p.prev>
<p.next @class="pagination-next">{{x-icon "chevron-right"}}</p.next>
<p.prev @class="pagination-previous">
{{x-icon "chevron-left"}}
</p.prev>
<p.next @class="pagination-next">
{{x-icon "chevron-right"}}
</p.next>
<ul class="pagination-list"></ul>
</nav>
</div>
</ListPagination>
{{else if this.allocations.length}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-allocations-list>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>
No Matches
</h3>
<p class="empty-message-body">
No allocations match the term
<strong>
{{this.searchTerm}}
</strong>
</p>
</div>
</div>
{{else}}
{{#if this.allocations.length}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-allocations-list>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>No Matches</h3>
<p class="empty-message-body">No allocations match the term <strong>{{this.searchTerm}}</strong></p>
</div>
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-allocations-list>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>
No Allocations
</h3>
<p class="empty-message-body">
No allocations have been placed.
</p>
</div>
{{else}}
<div class="boxed-section-body">
<div class="empty-message" data-test-empty-allocations-list>
<h3 class="empty-message-headline" data-test-empty-allocations-list-headline>No Allocations</h3>
<p class="empty-message-body">No allocations have been placed.</p>
</div>
</div>
{{/if}}
</div>
{{/if}}
</div>
</div>
<LifecycleChart @tasks={{this.model.tasks}} />
{{#if this.model.scaleState.isVisible}}
{{#if this.shouldShowScaleEventTimeline}}
<div data-test-scaling-timeline class="boxed-section">
@@ -166,7 +259,6 @@
</div>
</div>
{{/if}}
<div data-test-scaling-events class="boxed-section">
<div class="boxed-section-head">
Recent Scaling Events
@@ -176,7 +268,6 @@
</div>
</div>
{{/if}}
{{#if this.model.volumes.length}}
<div data-test-volumes class="boxed-section">
<div class="boxed-section-head">
@@ -185,29 +276,47 @@
<div class="boxed-section-body is-full-bleed">
<ListTable @source={{this.model.volumes}} as |t|>
<t.head>
<th>Name</th>
<th>Type</th>
<th>Source</th>
<th>Permissions</th>
<th>
Name
</th>
<th>
Type
</th>
<th>
Source
</th>
<th>
Permissions
</th>
</t.head>
<t.body as |row|>
<tr data-test-volume>
<td data-test-volume-name>
{{#if row.model.isCSI}}
<LinkTo @route="csi.volumes.volume" @model={{row.model.source}} @query={{hash volumeNamespace=row.model.namespace.id}}>
<LinkTo
@route="csi.volumes.volume"
@model={{row.model.source}}
@query={{hash volumeNamespace=row.model.namespace.id}}
>
{{row.model.name}}
</LinkTo>
{{else}}
{{row.model.name}}
{{/if}}
</td>
<td data-test-volume-type>{{row.model.type}}</td>
<td data-test-volume-source>{{row.model.source}}</td>
<td data-test-volume-permissions>{{if row.model.readOnly "Read" "Read/Write"}}</td>
<td data-test-volume-type>
{{row.model.type}}
</td>
<td data-test-volume-source>
{{row.model.source}}
</td>
<td data-test-volume-permissions>
{{if row.model.readOnly "Read" "Read/Write"}}
</td>
</tr>
</t.body>
</ListTable>
</div>
</div>
{{/if}}
</section>
</section>

View File

@@ -1,7 +1,5 @@
<Breadcrumb @crumb={{hash label="Run" args=(array "jobs.run")}} />
{{page-title "Run a job"}}
<section class="section">
<JobEditor
@job={{this.model}}
@context="new"
@onSubmit={{action this.onSubmit}} />
</section>
<JobEditor @job={{this.model}} @context="new" @onSubmit={{action this.onSubmit}} />
</section>

View File

@@ -1,3 +1,4 @@
<Breadcrumb @crumb={{hash label="Recommendations" args=(array "optimize")}} />
<PageLayout>
<section class="section">
{{#if this.summaries}}
@@ -8,7 +9,12 @@
data-test-recommendation-summaries-search
@onChange={{this.syncActiveSummary}}
@searchTerm={{mut this.searchTerm}}
@placeholder="Search {{this.summaries.length}} {{pluralize "recommendation" this.summaries.length}}..." />
@placeholder="Search
{{this.summaries.length}}
{{pluralize "recommendation" this.summaries.length}}
..."
/>
{{/if}}
</div>
<div class="toolbar-item is-right-aligned is-mobile-full-width">
@@ -19,72 +25,88 @@
@label="Namespace"
@options={{this.optionsNamespaces}}
@selection={{this.qpNamespace}}
@onSelect={{action (queue
(action this.cacheNamespace)
(action this.setFacetQueryParam "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"}} />
@onSelect={{action this.setFacetQueryParam "qpType"}}
/>
<MultiSelectDropdown
data-test-status-facet
@label="Status"
@options={{this.optionsStatus}}
@selection={{this.selectionStatus}}
@onSelect={{action this.setFacetQueryParam "qpStatus"}} />
@onSelect={{action this.setFacetQueryParam "qpStatus"}}
/>
<MultiSelectDropdown
data-test-datacenter-facet
@label="Datacenter"
@options={{this.optionsDatacenter}}
@selection={{this.selectionDatacenter}}
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}} />
@onSelect={{action this.setFacetQueryParam "qpDatacenter"}}
/>
<MultiSelectDropdown
data-test-prefix-facet
@label="Prefix"
@options={{this.optionsPrefix}}
@selection={{this.selectionPrefix}}
@onSelect={{action this.setFacetQueryParam "qpPrefix"}} />
@onSelect={{action this.setFacetQueryParam "qpPrefix"}}
/>
</div>
</div>
</div>
{{#if this.filteredSummaries}}
{{outlet}}
<ListTable
@source={{this.filteredSummaries}} as |t|>
<ListTable @source={{this.filteredSummaries}} as |t|>
<t.head>
<th>Job</th>
<th>Recommended At</th>
<th># Allocs</th>
<th>CPU</th>
<th>Mem</th>
<th>Agg. CPU</th>
<th>Agg. Mem</th>
<th>
Job
</th>
<th>
Recommended At
</th>
<th>
# Allocs
</th>
<th>
CPU
</th>
<th>
Mem
</th>
<th>
Agg. CPU
</th>
<th>
Agg. Mem
</th>
</t.head>
<t.body as |row|>
{{#if row.model.isProcessed}}
<Das::RecommendationRow
class="is-disabled"
@summary={{row.model}}
/>
<Das::RecommendationRow class="is-disabled" @summary={{row.model}} />
{{else}}
<Das::RecommendationRow
class="is-interactive {{if (eq row.model this.activeRecommendationSummary) 'is-active'}}"
class="is-interactive
{{if (eq row.model this.activeRecommendationSummary) "is-active"}}"
@summary={{row.model}}
{{on "click" (fn this.transitionToSummary row.model)}}
/>
{{/if}}
</t.body>
</ListTable>
{{else}}
<div class="empty-message" data-test-empty-recommendations>
<h3 class="empty-message-headline" data-test-empty-recommendations-headline>No Matches</h3>
<h3 class="empty-message-headline" data-test-empty-recommendations-headline>
No Matches
</h3>
<p class="empty-message-body">
No recommendations match your current filter selection.
</p>
@@ -92,11 +114,13 @@
{{/if}}
{{else}}
<div class="empty-message" data-test-empty-recommendations>
<h3 class="empty-message-headline" data-test-empty-recommendations-headline>No Recommendations</h3>
<h3 class="empty-message-headline" data-test-empty-recommendations-headline>
No Recommendations
</h3>
<p class="empty-message-body">
All recommendations have been accepted or dismissed. Nomad will continuously monitor applications so expect more recommendations in the future.
</p>
</div>
{{/if}}
</section>
</PageLayout>
</PageLayout>

View File

@@ -1,6 +1,2 @@
{{#if @model}}
<Das::RecommendationCard
@summary={{@model}}
@proceed={{this.optimizeController.proceed}}
/>
{{/if}}
<Breadcrumb @crumb={{this.breadcrumb}} />
<Das::RecommendationCard @summary={{@model}} @proceed={{this.optimizeController.proceed}} />

View File

@@ -1,3 +1,4 @@
<Breadcrumb @crumb={{hash label="Servers" args=(array "servers.index")}} />
<PageLayout>
{{outlet}}
</PageLayout>
</PageLayout>

View File

@@ -1 +1,4 @@
{{outlet}}
<Breadcrumb
@crumb={{hash title="Server" label=this.server.name args=(array "servers.server" this.server.id)}}
/>
{{outlet}}

View File

@@ -1,3 +1,4 @@
<Breadcrumb @crumb={{hash label="Topology" args=(array "topology")}} />
{{page-title "Cluster Topology"}}
<PageLayout>
<section class="section is-full-width">
@@ -8,11 +9,26 @@
<div class="notification is-warning">
<div data-test-filtered-nodes-warning class="columns">
<div class="column">
<h3 data-test-title class="title is-4">Some Clients Were Filtered</h3>
<p data-test-message>{{this.filteredNodes.length}} {{if (eq this.filteredNodes.length 1) "client was" "clients were"}} filtered from the topology visualization. This is most likely due to the {{pluralize "client" this.filteredNodes.length}} running a version of Nomad &lt;0.9.0.</p>
<h3 data-test-title class="title is-4">
Some Clients Were Filtered
</h3>
<p data-test-message>
{{this.filteredNodes.length}}
{{if (eq this.filteredNodes.length 1) "client was" "clients were"}}
filtered from the topology visualization. This is most likely due to the
{{pluralize "client" this.filteredNodes.length}}
running a version of Nomad
</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-dismiss class="button is-warning" onclick={{action (mut this.filteredNodes) null}} type="button">Okay</button>
<button
data-test-dismiss
class="button is-warning"
onclick={{action (mut this.filteredNodes) null}}
type="button"
>
Okay
</button>
</div>
</div>
</div>
@@ -23,11 +39,22 @@
<div class="notification is-warning">
<div class="columns">
<div class="column">
<h3 class="title is-4">No Live Updating</h3>
<p>The topology visualization depends on too much data to continuously poll.</p>
<h3 class="title is-4">
No Live Updating
</h3>
<p>
The topology visualization depends on too much data to continuously poll.
</p>
</div>
<div class="column is-centered is-minimum">
<button data-test-polling-notice-dismiss class="button is-warning" type="button" onclick={{toggle-action "showPollingNotice" this}}>Okay</button>
<button
data-test-polling-notice-dismiss
class="button is-warning"
type="button"
onclick={{toggle-action "showPollingNotice" this}}
>
Okay
</button>
</div>
</div>
</div>
@@ -36,154 +63,335 @@
<div class="boxed-section-head">
Legend
{{#if (cannot "list all jobs")}}
<span aria-label="Your ACL token may limit your ability to list all allocations" class="tag is-warning pull-right tooltip multiline">Partial View</span>
<span
aria-label="Your ACL token may limit your ability to list all allocations"
class="tag is-warning pull-right tooltip multiline"
>
Partial View
</span>
{{/if}}
</div>
<div class="boxed-section-body">
<div class="legend">
<h3 class="legend-label">Metrics</h3>
<h3 class="legend-label">
Metrics
</h3>
<dl class="legend-terms">
<dt>M:</dt><dd>Memory</dd>
<dt>C:</dt><dd>CPU</dd>
<dt>
M:
</dt>
<dd>
Memory
</dd>
<dt>
C:
</dt>
<dd>
CPU
</dd>
</dl>
</div>
<div class="legend">
<h3 class="legend-label">Allocation Status</h3>
<h3 class="legend-label">
Allocation Status
</h3>
<dl class="legend-terms">
<div class="legend-term"><dt><span class="color-swatch is-wide running" title="Running" /></dt><dd>Running</dd></div>
<div class="legend-term"><dt><span class="color-swatch is-wide pending" title="Starting" /></dt><dd>Starting</dd></div>
<div class="legend-term">
<dt>
<span class="color-swatch is-wide running" title="Running"></span>
</dt>
<dd>
Running
</dd>
</div>
<div class="legend-term">
<dt>
<span class="color-swatch is-wide pending" title="Starting"></span>
</dt>
<dd>
Starting
</dd>
</div>
</dl>
</div>
</div>
</div>
<div class="boxed-section">
<div data-test-info-panel-title class="boxed-section-head">
{{#if this.activeNode}}Client{{else if this.activeAllocation}}Allocation{{else}}Cluster{{/if}} Details
{{#if this.activeNode}}
Client
{{else if this.activeAllocation}}
Allocation
{{else}}
Cluster
{{/if}}
Details
</div>
<div data-test-info-panel class="boxed-section-body">
{{#if this.activeNode}}
{{#let this.activeNode.node as |node|}}
<div class="dashboard-metric">
<p data-test-allocations class="metric">{{this.activeNode.allocations.length}} <span class="metric-label">Allocations</span></p>
</div>
<div class="dashboard-metric">
<h3 class="pair">
<strong>Client:</strong>
<LinkTo data-test-client-link @route="clients.client" @model={{node}}>
{{node.shortId}}
</LinkTo>
</h3>
<p data-test-name class="minor-pair"><strong>Name:</strong> {{node.name}}</p>
<p data-test-address class="minor-pair"><strong>Address:</strong> {{node.httpAddr}}</p>
<p data-test-status class="minor-pair"><strong>Status:</strong> {{node.status}}</p>
</div>
<div class="dashboard-metric">
<h3 class="pair">
<strong>Draining?</strong> <span data-test-draining class="{{if node.isDraining "status-text is-info"}}">{{if node.isDraining "Yes" "No"}}</span>
</h3>
<h3 class="pair">
<strong>Eligible?</strong> <span data-test-eligible class="{{unless node.isEligible "status-text is-warning"}}">{{if node.isEligible "Yes" "No"}}</span>
</h3>
</div>
<div class="dashboard-metric with-divider">
<p class="metric">
{{this.nodeUtilization.totalMemoryFormatted}}
<span class="metric-units">{{this.nodeUtilization.totalMemoryUnits}}</span>
<span class="metric-label">of memory</span>
</p>
<div class="columns graphic">
<div class="column">
<div class="inline-chart">
<progress
data-test-memory-progress-bar
class="progress is-danger is-small"
value="{{this.nodeUtilization.reservedMemoryPercent}}"
max="1">
{{this.nodeUtilization.reservedMemoryPercent}}
</progress>
<div class="dashboard-metric">
<p data-test-allocations class="metric">
{{this.activeNode.allocations.length}}
<span class="metric-label">
Allocations
</span>
</p>
</div>
<div class="dashboard-metric">
<h3 class="pair">
<strong>
Client:
</strong>
<LinkTo data-test-client-link @route="clients.client" @model={{node}}>
{{node.shortId}}
</LinkTo>
</h3>
<p data-test-name class="minor-pair">
<strong>
Name:
</strong>
{{node.name}}
</p>
<p data-test-address class="minor-pair">
<strong>
Address:
</strong>
{{node.httpAddr}}
</p>
<p data-test-status class="minor-pair">
<strong>
Status:
</strong>
{{node.status}}
</p>
</div>
<div class="dashboard-metric">
<h3 class="pair">
<strong>
Draining?
</strong>
<span data-test-draining class="{{if node.isDraining "status-text is-info"}}">
{{if node.isDraining "Yes" "No"}}
</span>
</h3>
<h3 class="pair">
<strong>
Eligible?
</strong>
<span
data-test-eligible
class="{{unless node.isEligible "status-text is-warning"}}"
>
{{if node.isEligible "Yes" "No"}}
</span>
</h3>
</div>
<div class="dashboard-metric with-divider">
<p class="metric">
{{this.nodeUtilization.totalMemoryFormatted}}
<span class="metric-units">
{{this.nodeUtilization.totalMemoryUnits}}
</span>
<span class="metric-label">
of memory
</span>
</p>
<div class="columns graphic">
<div class="column">
<div class="inline-chart">
<progress
data-test-memory-progress-bar
class="progress is-danger is-small"
value="{{this.nodeUtilization.reservedMemoryPercent}}"
max="1"
>
{{this.nodeUtilization.reservedMemoryPercent}}
</progress>
</div>
</div>
<div class="column is-minimum">
<span class="nowrap" data-test-percentage>
{{format-percentage this.nodeUtilization.reservedMemoryPercent total=1}}
</span>
</div>
</div>
<div class="column is-minimum">
<span class="nowrap" data-test-percentage>{{format-percentage this.nodeUtilization.reservedMemoryPercent total=1}}</span>
<div class="annotation" data-test-memory-absolute-value>
<strong>
{{format-scheduled-bytes this.nodeUtilization.totalReservedMemory}}
</strong>
/
{{format-scheduled-bytes this.nodeUtilization.totalMemory}}
reserved
</div>
</div>
<div class="annotation" data-test-memory-absolute-value>
<strong>{{format-scheduled-bytes this.nodeUtilization.totalReservedMemory}}</strong> / {{format-scheduled-bytes this.nodeUtilization.totalMemory}} reserved
</div>
</div>
<div class="dashboard-metric">
<p class="metric">{{this.nodeUtilization.totalCPU}} <span class="metric-units">MHz</span> <span class="metric-label">of CPU</span></p>
<div class="columns graphic">
<div class="column">
<div class="inline-chart" data-test-percentage-bar>
<progress
data-test-cpu-progress-bar
class="progress is-info is-small"
value="{{this.nodeUtilization.reservedCPUPercent}}"
max="1">
{{this.nodeUtilization.reservedCPUPercent}}
</progress>
<div class="dashboard-metric">
<p class="metric">
{{this.nodeUtilization.totalCPU}}
<span class="metric-units">
MHz
</span>
<span class="metric-label">
of CPU
</span>
</p>
<div class="columns graphic">
<div class="column">
<div class="inline-chart" data-test-percentage-bar>
<progress
data-test-cpu-progress-bar
class="progress is-info is-small"
value="{{this.nodeUtilization.reservedCPUPercent}}"
max="1"
>
{{this.nodeUtilization.reservedCPUPercent}}
</progress>
</div>
</div>
<div class="column is-minimum">
<span class="nowrap" data-test-percentage>
{{format-percentage this.nodeUtilization.reservedCPUPercent total=1}}
</span>
</div>
</div>
<div class="column is-minimum">
<span class="nowrap" data-test-percentage>{{format-percentage this.nodeUtilization.reservedCPUPercent total=1}}</span>
<div class="annotation" data-test-cpu-absolute-value>
<strong>
{{format-scheduled-hertz this.nodeUtilization.totalReservedCPU}}
</strong>
/
{{format-scheduled-hertz this.nodeUtilization.totalCPU}}
reserved
</div>
</div>
<div class="annotation" data-test-cpu-absolute-value>
<strong>{{format-scheduled-hertz this.nodeUtilization.totalReservedCPU}}</strong> / {{format-scheduled-hertz this.nodeUtilization.totalCPU}} reserved
</div>
</div>
{{/let}}
{{else if this.activeAllocation}}
<div class="dashboard-metric">
<h3 class="pair">
<strong>Allocation:</strong>
<LinkTo data-test-id @route="allocations.allocation" @model={{this.activeAllocation}} class="is-primary">{{this.activeAllocation.shortId}}</LinkTo>
<strong>
Allocation:
</strong>
<LinkTo
data-test-id
@route="allocations.allocation"
@model={{this.activeAllocation}}
class="is-primary"
>
{{this.activeAllocation.shortId}}
</LinkTo>
</h3>
<p data-test-sibling-allocs class="minor-pair"><strong>Sibling Allocations:</strong> {{this.siblingAllocations.length}}</p>
<p data-test-unique-placements class="minor-pair"><strong>Unique Client Placements:</strong> {{this.uniqueActiveAllocationNodes.length}}</p>
<p data-test-sibling-allocs class="minor-pair">
<strong>
Sibling Allocations:
</strong>
{{this.siblingAllocations.length}}
</p>
<p data-test-unique-placements class="minor-pair">
<strong>
Unique Client Placements:
</strong>
{{this.uniqueActiveAllocationNodes.length}}
</p>
</div>
<div class="dashboard-metric with-divider">
<h3 class="pair">
<strong>Job:</strong>
<strong>
Job:
</strong>
<LinkTo
data-test-job
@route="jobs.job"
@model={{this.activeAllocation.job}}
@query={{hash jobNamespace=this.activeAllocation.job.namespace.id}}>
{{this.activeAllocation.job.name}}</LinkTo>
<span class="is-faded" data-test-task-group> / {{this.activeAllocation.taskGroupName}}</span>
@query={{hash jobNamespace=this.activeAllocation.job.namespace.id}}
>
{{this.activeAllocation.job.name}}
</LinkTo>
<span class="is-faded" data-test-task-group>
/
{{this.activeAllocation.taskGroupName}}
</span>
</h3>
<p class="minor-pair"><strong>Type:</strong> {{this.activeAllocation.job.type}}</p>
<p class="minor-pair"><strong>Priority:</strong> {{this.activeAllocation.job.priority}}</p>
<p class="minor-pair">
<strong>
Type:
</strong>
{{this.activeAllocation.job.type}}
</p>
<p class="minor-pair">
<strong>
Priority:
</strong>
{{this.activeAllocation.job.priority}}
</p>
</div>
<div class="dashboard-metric with-divider">
<h3 class="pair">
<strong>Client:</strong>
<LinkTo data-test-client @route="clients.client" @model={{this.activeAllocation.node}}>
<strong>
Client:
</strong>
<LinkTo
data-test-client
@route="clients.client"
@model={{this.activeAllocation.node}}
>
{{this.activeAllocation.node.shortId}}
</LinkTo>
</h3>
<p class="minor-pair"><strong>Name:</strong> {{this.activeAllocation.node.name}}</p>
<p class="minor-pair"><strong>Address:</strong> {{this.activeAllocation.node.httpAddr}}</p>
<p class="minor-pair">
<strong>
Name:
</strong>
{{this.activeAllocation.node.name}}
</p>
<p class="minor-pair">
<strong>
Address:
</strong>
{{this.activeAllocation.node.httpAddr}}
</p>
</div>
<div class="dashboard-metric with-divider">
<PrimaryMetric::Allocation @allocation={{this.activeAllocation}} @metric="memory" class="is-short" />
<PrimaryMetric::Allocation
@allocation={{this.activeAllocation}}
@metric="memory"
class="is-short"
/>
</div>
<div class="dashboard-metric">
<PrimaryMetric::Allocation @allocation={{this.activeAllocation}} @metric="cpu" class="is-short" />
<PrimaryMetric::Allocation
@allocation={{this.activeAllocation}}
@metric="cpu"
class="is-short"
/>
</div>
{{else}}
<div class="columns is-flush">
<div class="dashboard-metric column">
<p data-test-node-count class="metric">{{this.model.nodes.length}} <span class="metric-label">Clients</span></p>
<p data-test-node-count class="metric">
{{this.model.nodes.length}}
<span class="metric-label">
Clients
</span>
</p>
</div>
<div class="dashboard-metric column">
<p data-test-alloc-count class="metric">{{this.scheduledAllocations.length}} <span class="metric-label">Allocations</span></p>
<p data-test-alloc-count class="metric">
{{this.scheduledAllocations.length}}
<span class="metric-label">
Allocations
</span>
</p>
</div>
</div>
<div class="dashboard-metric with-divider">
<p class="metric">{{this.totalMemoryFormatted}} <span class="metric-units">{{this.totalMemoryUnits}}</span> <span class="metric-label">of memory</span></p>
<p class="metric">
{{this.totalMemoryFormatted}}
<span class="metric-units">
{{this.totalMemoryUnits}}
</span>
<span class="metric-label">
of memory
</span>
</p>
<div class="columns graphic">
<div class="column">
<div class="inline-chart" data-test-percentage-bar>
@@ -191,21 +399,37 @@
data-test-memory-progress-bar
class="progress is-danger is-small"
value="{{this.reservedMemoryPercent}}"
max="1">
max="1"
>
{{this.reservedMemoryPercent}}
</progress>
</div>
</div>
<div class="column is-minimum">
<span class="nowrap" data-test-memory-percentage>{{format-percentage this.reservedMemoryPercent total=1}}</span>
<span class="nowrap" data-test-memory-percentage>
{{format-percentage this.reservedMemoryPercent total=1}}
</span>
</div>
</div>
<div class="annotation" data-test-memory-absolute-value>
<strong>{{format-bytes this.totalReservedMemory}}</strong> / {{format-bytes this.totalMemory}} reserved
<strong>
{{format-bytes this.totalReservedMemory}}
</strong>
/
{{format-bytes this.totalMemory}}
reserved
</div>
</div>
<div class="dashboard-metric">
<p class="metric">{{this.totalCPUFormatted}} <span class="metric-units">{{this.totalCPUUnits}}</span> <span class="metric-label">of CPU</span></p>
<p class="metric">
{{this.totalCPUFormatted}}
<span class="metric-units">
{{this.totalCPUUnits}}
</span>
<span class="metric-label">
of CPU
</span>
</p>
<div class="columns graphic">
<div class="column">
<div class="inline-chart" data-test-percentage-bar>
@@ -213,17 +437,25 @@
data-test-cpu-progress-bar
class="progress is-info is-small"
value="{{this.reservedCPUPercent}}"
max="1">
max="1"
>
{{this.reservedCPUPercent}}
</progress>
</div>
</div>
<div class="column is-minimum">
<span class="nowrap" data-test-cpu-percentage>{{format-percentage this.reservedCPUPercent total=1}}</span>
<span class="nowrap" data-test-cpu-percentage>
{{format-percentage this.reservedCPUPercent total=1}}
</span>
</div>
</div>
<div class="annotation" data-test-cpu-absolute-value>
<strong>{{format-hertz this.totalReservedCPU}}</strong> / {{format-hertz this.totalCPU}} reserved
<strong>
{{format-hertz this.totalReservedCPU}}
</strong>
/
{{format-hertz this.totalCPU}}
reserved
</div>
</div>
{{/if}}
@@ -236,9 +468,10 @@
@allocations={{this.model.allocations}}
@onAllocationSelect={{action this.setAllocation}}
@onNodeSelect={{action this.setNode}}
@onDataError={{action this.handleTopoVizDataError}}/>
@onDataError={{action this.handleTopoVizDataError}}
/>
</div>
</div>
{{/if}}
</section>
</PageLayout>
</PageLayout>

View File

@@ -1,28 +0,0 @@
import PromiseObject from 'nomad-ui/utils/classes/promise-object';
import { qpBuilder } from 'nomad-ui/utils/classes/query-params';
export const jobCrumb = job => ({
label: job.get('trimmedName'),
args: [
'jobs.job.index',
job.get('plainId'),
qpBuilder({
jobNamespace: job.get('namespace.name') || 'default',
}),
],
});
export const jobCrumbs = job => {
if (!job) return [];
if (job.get('parent.content')) {
return [
PromiseObject.create({
promise: job.get('parent').then(parent => jobCrumb(parent)),
}),
jobCrumb(job),
];
} else {
return [jobCrumb(job)];
}
};

View File

@@ -94,6 +94,7 @@
"ember-power-select": "^4.1.3",
"ember-qunit": "^4.6.0",
"ember-qunit-nice-errors": "^1.2.0",
"ember-render-helpers": "^0.2.0",
"ember-resolver": "^8.0.0",
"ember-responsive": "^3.0.4",
"ember-sinon": "^4.0.0",

View File

@@ -68,8 +68,8 @@ module('Acceptance | client detail', function(hooks) {
);
assert.equal(
Layout.breadcrumbFor('clients.client').text,
node.id.split('-')[0],
'Second breadcrumb says the node short id'
`Client ${node.id.split('-')[0]}`,
'Second breadcrumb is a titled breadcrumb saying the node short id'
);
await Layout.breadcrumbFor('clients.index').visit();
assert.equal(currentURL(), '/clients', 'First breadcrumb links back to clients');

View File

@@ -36,7 +36,7 @@ module('Acceptance | client monitor', function(hooks) {
await ClientMonitor.visit({ id: node.id });
assert.equal(Layout.breadcrumbFor('clients.index').text, 'Clients');
assert.equal(Layout.breadcrumbFor('clients.client').text, node.id.split('-')[0]);
assert.equal(Layout.breadcrumbFor('clients.client').text, `Client ${node.id.split('-')[0]}`);
await Layout.breadcrumbFor('clients.index').visit();
assert.equal(currentURL(), '/clients');

View File

@@ -33,9 +33,12 @@ module('Acceptance | server monitor', function(hooks) {
test('/servers/:id/monitor should have a breadcrumb trail linking back to servers', async function(assert) {
await ServerMonitor.visit({ name: agent.name });
assert.equal(Layout.breadcrumbFor('servers.index').text, 'Servers');
assert.equal(Layout.breadcrumbFor('servers.server').text, agent.name);
assert.equal(
Layout.breadcrumbFor('servers.index').text,
'Servers',
'The page should read the breadcrumb Servers'
);
assert.equal(Layout.breadcrumbFor('servers.server').text, `Server ${agent.name}`);
await Layout.breadcrumbFor('servers.index').visit();
assert.equal(currentURL(), '/servers');

View File

@@ -1,4 +1,4 @@
import { currentURL } from '@ember/test-helpers';
import { currentURL, waitFor } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
@@ -57,26 +57,27 @@ module('Acceptance | task detail', function(hooks) {
const job = server.db.jobs.find(jobId);
const shortId = allocation.id.split('-')[0];
assert.equal(Layout.breadcrumbFor('jobs.index').text, 'Jobs', 'Jobs is the first breadcrumb');
await waitFor('[data-test-job-breadcrumb]');
assert.equal(
Layout.breadcrumbFor('jobs.job.index').text,
job.name,
`Job ${job.name}`,
'Job is the second breadcrumb'
);
assert.equal(
Layout.breadcrumbFor('jobs.job.task-group').text,
taskGroup,
`Task Group ${taskGroup}`,
'Task Group is the third breadcrumb'
);
assert.equal(
Layout.breadcrumbFor('allocations.allocation').text,
shortId,
`Allocation ${shortId}`,
'Allocation short id is the fourth breadcrumb'
);
assert.equal(
Layout.breadcrumbFor('allocations.allocation.task').text,
task.name,
`Task ${task.name}`,
'Task name is the fifth breadcrumb'
);

View File

@@ -122,12 +122,12 @@ module('Acceptance | task group detail', function(hooks) {
assert.equal(Layout.breadcrumbFor('jobs.index').text, 'Jobs', 'First breadcrumb says jobs');
assert.equal(
Layout.breadcrumbFor('jobs.job.index').text,
job.name,
`Job ${job.name}`,
'Second breadcrumb says the job name'
);
assert.equal(
Layout.breadcrumbFor('jobs.job.task-group').text,
taskGroup.name,
`Task Group ${taskGroup.name}`,
'Third breadcrumb says the job name'
);
});

View File

@@ -1,49 +1,31 @@
import Service from '@ember/service';
import RSVP from 'rsvp';
/* eslint-disable ember-a11y-testing/a11y-audit-called */
import { setComponentTemplate } from '@ember/component';
import Component from '@glimmer/component';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { findAll, render, settled } from '@ember/test-helpers';
import { findAll, render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import PromiseObject from 'nomad-ui/utils/classes/promise-object';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
module('Integration | Component | app breadcrumbs', function(hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function() {
const mockBreadcrumbs = Service.extend({
init() {
this._super(...arguments);
this.breadcrumbs = [];
},
});
this.owner.register('service:breadcrumbs', mockBreadcrumbs);
this.breadcrumbs = this.owner.lookup('service:breadcrumbs');
});
const commonCrumbs = [{ label: 'One', args: ['one'] }, { label: 'Two', args: ['two'] }];
const template = hbs`
<ul><AppBreadcrumbs /></ul>
`;
test('breadcrumbs comes from the breadcrumbs service', async function(assert) {
this.breadcrumbs.set('breadcrumbs', commonCrumbs);
await render(template);
assert.equal(
findAll('[data-test-breadcrumb]').length,
commonCrumbs.length,
'The number of crumbs matches the crumbs from the service'
);
});
const commonCrumbs = [
{ label: 'Jobs', args: ['jobs.index'] },
{ label: 'Job', args: ['jobs.job.index'] },
];
test('every breadcrumb is rendered correctly', async function(assert) {
this.breadcrumbs.set('breadcrumbs', commonCrumbs);
this.set('commonCrumbs', commonCrumbs);
await render(hbs`
<AppBreadcrumbs />
{{#each this.commonCrumbs as |crumb|}}
<Breadcrumb @crumb={{hash label=crumb.label args=crumb.args }} />
{{/each}}
`);
await render(template);
assert
.dom('[data-test-breadcrumb-default]')
.exists('We register the default breadcrumb component if no type is specified on the crumb');
const renderedCrumbs = findAll('[data-test-breadcrumb]');
@@ -56,36 +38,39 @@ module('Integration | Component | app breadcrumbs', function(hooks) {
});
});
test('when breadcrumbs are pending promises, an ellipsis is rendered', async function(assert) {
let resolvePromise;
const promise = new RSVP.Promise(resolve => {
resolvePromise = resolve;
});
test('when we register a crumb with a type property, a dedicated breadcrumb/<type> component renders', async function(assert) {
const crumbs = [
{ label: 'Jobs', args: ['jobs.index'] },
{ type: 'special', label: 'Job', args: ['jobs.job.index'] },
];
this.set('crumbs', crumbs);
this.breadcrumbs.set('breadcrumbs', [
{ label: 'One', args: ['one'] },
PromiseObject.create({ promise }),
{ label: 'Three', args: ['three'] },
]);
await render(template);
assert.equal(
findAll('[data-test-breadcrumb]')[1].textContent.trim(),
'…',
'Promise breadcrumb is in a loading state'
class MockComponent extends Component {}
this.owner.register(
'component:breadcrumbs/special',
setComponentTemplate(
hbs`
<div data-test-breadcrumb-special>Test</div>
`,
MockComponent
)
);
await componentA11yAudit(this.element, assert);
await render(hbs`
<AppBreadcrumbs />
{{#each this.crumbs as |crumb|}}
<Breadcrumb @crumb={{hash type=crumb.type label=crumb.label args=crumb.args }} />
{{/each}}
`);
resolvePromise({ label: 'Two', args: ['two'] });
return settled().then(() => {
assert.equal(
findAll('[data-test-breadcrumb]')[1].textContent.trim(),
'Two',
'Promise breadcrumb has resolved and now renders Two'
assert
.dom('[data-test-breadcrumb-special]')
.exists(
'We can create a new type of breadcrumb component and AppBreadcrumbs will handle rendering by type'
);
});
assert
.dom('[data-test-breadcrumb-default]')
.exists('Default breadcrumb registers if no type is specified');
});
});

View File

@@ -0,0 +1,67 @@
/* eslint-disable ember-a11y-testing/a11y-audit-called */
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, findAll, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | breadcrumbs', function(hooks) {
setupRenderingTest(hooks);
test('it declaratively renders a list of registered crumbs', async function(assert) {
this.set('isRegistered', false);
this.set('toggleCrumb', () => this.set('isRegistered', !this.isRegistered));
await render(hbs`
<Breadcrumbs as |bb|>
<ul>
{{#each bb as |crumb|}}
<li data-test-crumb={{crumb.args.crumb}}>{{crumb.args.crumb}}</li>
{{/each}}
</ul>
</Breadcrumbs>
<button data-test-button type="button" {{on "click" toggleCrumb}}>Toggle</button>
<Breadcrumb @crumb={{'Zoey'}} />
{{#if this.isRegistered}}
<Breadcrumb @crumb={{'Tomster'}} />
{{/if}}
`);
assert.dom('[data-test-crumb]').exists({ count: 1 }, 'We register one crumb');
assert.dom('[data-test-crumb]').hasText('Zoey', 'The first registered crumb is Zoey');
await click('[data-test-button]');
const crumbs = await findAll('[data-test-crumb]');
assert
.dom('[data-test-crumb]')
.exists({ count: 2 }, 'The second crumb registered successfully');
assert
.dom(crumbs[0])
.hasText('Zoey', 'Breadcrumbs maintain the order in which they are declared');
assert
.dom(crumbs[1])
.hasText('Tomster', 'Breadcrumbs maintain the order in which they are declared');
await click('[data-test-button]');
assert.dom('[data-test-crumb]').exists({ count: 1 }, 'We deregister one crumb');
assert
.dom('[data-test-crumb]')
.hasText('Zoey', 'Zoey remains in the template after Tomster deregisters');
});
test('it can register complex crumb objects', async function(assert) {
await render(hbs`
<Breadcrumbs as |bb|>
<ul>
{{#each bb as |crumb|}}
<li data-test-crumb>{{crumb.args.crumb.name}}</li>
{{/each}}
</ul>
</Breadcrumbs>
<Breadcrumb @crumb={{hash name='Tomster'}} />
`);
assert
.dom('[data-test-crumb]')
.hasText('Tomster', 'We can access the registered breadcrumbs in the template');
});
});

View File

@@ -0,0 +1,191 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, waitFor } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | trigger', function(hooks) {
setupRenderingTest(hooks);
module('Synchronous Interactions', function() {
test('it can trigger a synchronous action', async function(assert) {
this.set('name', 'Tomster');
this.set('changeName', () => this.set('name', 'Zoey'));
await render(hbs`
<Trigger @do={{this.changeName}} as |trigger|>
<h2 data-test-name>{{this.name}}</h2>
<button data-test-button type="button" {{on "click" trigger.fns.do}}>Change my name.</button>
</Trigger>
`);
assert.dom('[data-test-name]').hasText('Tomster', 'Initial state renders correctly.');
await click('[data-test-button]');
assert
.dom('[data-test-name]')
.hasText('Zoey', 'The name property changes when the button is clicked');
});
test('it sets the result of the action', async function(assert) {
this.set('tomster', () => 'Tomster');
await render(hbs`
<Trigger @do={{this.tomster}} as |trigger|>
{{#if trigger.data.result}}
<h2 data-test-name>{{trigger.data.result}}</h2>
{{/if}}
<button data-test-button {{on "click" trigger.fns.do}}>Generate</button>
</Trigger>
`);
assert
.dom('[data-test-name]')
.doesNotExist('Initial state does not render because there is no result yet.');
await click('[data-test-button]');
assert
.dom('[data-test-name]')
.hasText('Tomster', 'The result state updates after the triggered action');
});
});
module('Asynchronous Interactions', function() {
test('it can trigger an asynchronous action', async function(assert) {
this.set(
'onTrigger',
() =>
new Promise(resolve => {
this.set('resolve', resolve);
})
);
await render(hbs`
<Trigger @do={{this.onTrigger}} as |trigger|>
{{#if trigger.data.isBusy}}
<div data-test-div-loading>...Loading</div>
{{/if}}
{{#if trigger.data.isSuccess}}
<div data-test-div>Success!</div>
{{/if}}
<button data-test-button {{on "click" trigger.fns.do}}>Click Me</button>
</Trigger>
`);
assert
.dom('[data-test-div]')
.doesNotExist('The div does not render until after the action dispatches successfully');
await click('[data-test-button]');
assert
.dom('[data-test-div-loading]')
.exists('Loading state is displayed when the action hasnt resolved yet');
assert
.dom('[data-test-div]')
.doesNotExist('Success message does not display until after promise resolves');
this.resolve();
await waitFor('[data-test-div]');
assert
.dom('[data-test-div-loading]')
.doesNotExist(
'Loading state is no longer rendered after state changes from busy to success'
);
assert
.dom('[data-test-div]')
.exists('Action has dispatched successfully after the promise resolves');
await click('[data-test-button]');
assert
.dom('[data-test-div]')
.doesNotExist('Aftering clicking the button, again, the state is reset');
assert
.dom('[data-test-div-loading]')
.exists('After clicking the button, again, we are back in the loading state');
this.resolve();
await waitFor('[data-test-div]');
assert
.dom('[data-test-div]')
.exists(
'An new action and new promise resolve after clicking the button for the second time'
);
});
test('it handles the success state', async function(assert) {
this.set(
'onTrigger',
() =>
new Promise(resolve => {
this.set('resolve', resolve);
})
);
this.set('onSuccess', () => assert.step('On success happened'));
await render(hbs`
<Trigger @do={{this.onTrigger}} @onSuccess={{this.onSuccess}} as |trigger|>
{{#if trigger.data.isSuccess}}
<span data-test-div>Success!</span>
{{/if}}
<button data-test-button {{on "click" trigger.fns.do}}>Click Me</button>
</Trigger>
`);
assert
.dom('[data-test-div]')
.doesNotExist('No text should appear until after the onSuccess callback is fired');
await click('[data-test-button]');
this.resolve();
await waitFor('[data-test-div]');
assert.verifySteps(['On success happened']);
});
test('it handles the error state', async function(assert) {
this.set(
'onTrigger',
() =>
new Promise((_, reject) => {
this.set('reject', reject);
})
);
this.set('onError', () => {
assert.step('On error happened');
});
await render(hbs`
<Trigger @do={{this.onTrigger}} @onError={{this.onError}} as |trigger|>
{{#if trigger.data.isBusy}}
<div data-test-div-loading>...Loading</div>
{{/if}}
{{#if trigger.data.isError}}
<span data-test-span>Error!</span>
{{/if}}
<button data-test-button {{on "click" trigger.fns.do}}>Click Me</button>
</Trigger>
`);
await click('[data-test-button]');
assert
.dom('[data-test-div-loading]')
.exists('Loading state is displayed when the action hasnt resolved yet');
assert
.dom('[data-test-div]')
.doesNotExist('No text should appear until after the onError callback is fired');
this.reject();
await waitFor('[data-test-span]');
assert.verifySteps(['On error happened']);
await click('[data-test-button]');
assert
.dom('[data-test-div-loading]')
.exists('The previous error state was cleared and we show loading, again.');
assert.dom('[data-test-div]').doesNotExist('The error state is cleared');
this.reject();
await waitFor('[data-test-span]');
assert.verifySteps(['On error happened'], 'The error dispatches');
});
});
});

View File

@@ -1,151 +0,0 @@
import Service from '@ember/service';
import Route from '@ember/routing/route';
import Controller from '@ember/controller';
import { get } from '@ember/object';
import { alias } from '@ember/object/computed';
import RSVP from 'rsvp';
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import PromiseObject from 'nomad-ui/utils/classes/promise-object';
const makeRoute = (crumbs, controller = {}) =>
Route.extend({
breadcrumbs: crumbs,
controller: Controller.extend(controller).create(),
});
module('Unit | Service | Breadcrumbs', function(hooks) {
setupTest(hooks);
hooks.beforeEach(function() {
this.subject = function() {
return this.owner.factoryFor('service:breadcrumbs').create();
};
});
hooks.beforeEach(function() {
const mockRouter = Service.extend({
currentRouteName: 'application',
currentURL: '/',
});
this.owner.register('service:router', mockRouter);
this.router = this.owner.lookup('service:router');
const none = makeRoute();
const fixed = makeRoute([{ label: 'Static', args: ['static.index'] }]);
const manyFixed = makeRoute([
{ label: 'Static 1', args: ['static.index', 1] },
{ label: 'Static 2', args: ['static.index', 2] },
]);
const dynamic = makeRoute(model => [{ label: model, args: ['dynamic.index', model] }], {
model: 'Label of the Crumb',
});
const manyDynamic = makeRoute(
model => [
{ label: get(model, 'fishOne'), args: ['dynamic.index', get(model, 'fishOne')] },
{ label: get(model, 'fishTwo'), args: ['dynamic.index', get(model, 'fishTwo')] },
],
{
model: {
fishOne: 'red',
fishTwo: 'blue',
},
}
);
const promise = makeRoute([
PromiseObject.create({
promise: RSVP.Promise.resolve({
label: 'delayed',
args: ['wait.for.it'],
}),
}),
]);
const fromURL = makeRoute(model => [{ label: model, args: ['url'] }], {
router: this.owner.lookup('service:router'),
model: alias('router.currentURL'),
});
this.owner.register('route:none', none);
this.owner.register('route:none.more-none', none);
this.owner.register('route:static', fixed);
this.owner.register('route:static.many', manyFixed);
this.owner.register('route:dynamic', dynamic);
this.owner.register('route:dynamic.many', manyDynamic);
this.owner.register('route:promise', promise);
this.owner.register('route:url', fromURL);
});
test('when the route hierarchy has no breadcrumbs', function(assert) {
this.router.set('currentRouteName', 'none');
const service = this.subject();
assert.deepEqual(service.get('breadcrumbs'), []);
});
test('when the route hierarchy has one segment with static crumbs', function(assert) {
this.router.set('currentRouteName', 'static');
const service = this.subject();
assert.deepEqual(service.get('breadcrumbs'), [{ label: 'Static', args: ['static.index'] }]);
});
test('when the route hierarchy has multiple segments with static crumbs', function(assert) {
this.router.set('currentRouteName', 'static.many');
const service = this.subject();
assert.deepEqual(service.get('breadcrumbs'), [
{ label: 'Static', args: ['static.index'] },
{ label: 'Static 1', args: ['static.index', 1] },
{ label: 'Static 2', args: ['static.index', 2] },
]);
});
test('when the route hierarchy has a function as its breadcrumbs property', function(assert) {
this.router.set('currentRouteName', 'dynamic');
const service = this.subject();
assert.deepEqual(service.get('breadcrumbs'), [
{ label: 'Label of the Crumb', args: ['dynamic.index', 'Label of the Crumb'] },
]);
});
test('when the route hierarchy has multiple segments with dynamic crumbs', function(assert) {
this.router.set('currentRouteName', 'dynamic.many');
const service = this.subject();
assert.deepEqual(service.get('breadcrumbs'), [
{ label: 'Label of the Crumb', args: ['dynamic.index', 'Label of the Crumb'] },
{ label: 'red', args: ['dynamic.index', 'red'] },
{ label: 'blue', args: ['dynamic.index', 'blue'] },
]);
});
test('when a route provides a breadcrumb that is a promise, it gets passed through to the template', function(assert) {
this.router.set('currentRouteName', 'promise');
const service = this.subject();
assert.ok(service.get('breadcrumbs.firstObject') instanceof PromiseObject);
});
// This happens when transitioning to the current route but with a different model
// jobs.job.index --> jobs.job.index
// /jobs/one --> /jobs/two
test('when the route stays the same but the url changes, breadcrumbs get recomputed', function(assert) {
this.router.set('currentRouteName', 'url');
const service = this.subject();
assert.deepEqual(
service.get('breadcrumbs'),
[{ label: '/', args: ['url'] }],
'The label is initially / as is the router currentURL'
);
this.router.set('currentURL', '/somewhere/else');
assert.deepEqual(
service.get('breadcrumbs'),
[{ label: '/somewhere/else', args: ['url'] }],
'The label changes with currentURL since it is an alias and a change to currentURL recomputes breadcrumbs'
);
});
});

View File

@@ -4345,6 +4345,13 @@ ansi-to-html@^0.6.11, ansi-to-html@^0.6.6:
dependencies:
entities "^1.1.2"
ansi-to-html@^0.6.15:
version "0.6.15"
resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.6.15.tgz#ac6ad4798a00f6aa045535d7f6a9cb9294eebea7"
integrity sha512-28ijx2aHJGdzbs+O5SNQF65r6rrKYnkuwTYm8lZlChuoJ9P1vVzIpWO20sQTqTPDXYp6NFwk326vApTtLVFXpQ==
dependencies:
entities "^2.0.0"
ansicolors@~0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.2.1.tgz#be089599097b74a5c9c4a84a0cdbcdb62bd87aef"
@@ -8925,6 +8932,22 @@ ember-cli-typescript@^3.0.0, ember-cli-typescript@^3.1.3, ember-cli-typescript@^
stagehand "^1.0.0"
walk-sync "^2.0.0"
ember-cli-typescript@^4.0.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/ember-cli-typescript/-/ember-cli-typescript-4.2.1.tgz#54d08fc90318cc986f3ea562f93ce58a6cc4c24d"
integrity sha512-0iKTZ+/wH6UB/VTWKvGuXlmwiE8HSIGcxHamwNhEC5x1mN3z8RfvsFZdQWYUzIWFN2Tek0gmepGRPTwWdBYl/A==
dependencies:
ansi-to-html "^0.6.15"
broccoli-stew "^3.0.0"
debug "^4.0.0"
execa "^4.0.0"
fs-extra "^9.0.1"
resolve "^1.5.0"
rsvp "^4.8.1"
semver "^7.3.2"
stagehand "^1.0.0"
walk-sync "^2.2.0"
ember-cli-typescript@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ember-cli-typescript/-/ember-cli-typescript-4.1.0.tgz#2ff17be2e6d26b58c88b1764cb73887e7176618b"
@@ -9426,6 +9449,14 @@ ember-qunit@^4.6.0:
ember-cli-test-loader "^2.2.0"
qunit "^2.9.3"
ember-render-helpers@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/ember-render-helpers/-/ember-render-helpers-0.2.0.tgz#5f7af8ee74ae29f85e0d156b2775edff23f6de21"
integrity sha512-MnqGS8BnY3GJ+n5RZVVRqCwKjfXXMr5quKyqNu1vxft8oslOJuZ1f1dOesQouD+6LwD4Y9tWRVKNw+LOqM9ocw==
dependencies:
ember-cli-babel "^7.23.0"
ember-cli-typescript "^4.0.0"
ember-resolver@^8.0.0:
version "8.0.2"
resolved "https://registry.yarnpkg.com/ember-resolver/-/ember-resolver-8.0.2.tgz#8a45a744aaf5391eb52b4cb393b3b06d2db1975c"