diff --git a/ui/app/adapters/service.js b/ui/app/adapters/service.js new file mode 100644 index 00000000000..754e840b84c --- /dev/null +++ b/ui/app/adapters/service.js @@ -0,0 +1,5 @@ +import Watchable from './watchable'; +import classic from 'ember-classic-decorator'; + +@classic +export default class ServiceAdapter extends Watchable {} diff --git a/ui/app/adapters/watchable.js b/ui/app/adapters/watchable.js index 05dd103e2ff..da95d8ba159 100644 --- a/ui/app/adapters/watchable.js +++ b/ui/app/adapters/watchable.js @@ -143,9 +143,9 @@ export default class Watchable extends ApplicationAdapter { reloadRelationship( model, relationshipName, - options = { watch: false, abortController: null } + options = { watch: false, abortController: null, replace: false } ) { - const { watch, abortController } = options; + const { watch, abortController, replace } = options; const relationship = model.relationshipFor(relationshipName); if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') { throw new Error( @@ -185,6 +185,9 @@ export default class Watchable extends ApplicationAdapter { modelClass, json ); + if (replace) { + store.unloadAll(relationship.type); + } store.push(normalizedData); }, (error) => { diff --git a/ui/app/components/job-service-row.js b/ui/app/components/job-service-row.js new file mode 100644 index 00000000000..945864c4be7 --- /dev/null +++ b/ui/app/components/job-service-row.js @@ -0,0 +1,17 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class JobServiceRowComponent extends Component { + @service router; + + @action + gotoService(service) { + if (service.provider === 'nomad') { + this.router.transitionTo('jobs.job.services.service', service.name, { + queryParams: { level: service.level }, + instances: service.instances, + }); + } + } +} diff --git a/ui/app/controllers/jobs/job/services.js b/ui/app/controllers/jobs/job/services.js new file mode 100644 index 00000000000..642207e6b35 --- /dev/null +++ b/ui/app/controllers/jobs/job/services.js @@ -0,0 +1,3 @@ +import Controller from '@ember/controller'; + +export default class JobsJobServicesController extends Controller {} diff --git a/ui/app/controllers/jobs/job/services/index.js b/ui/app/controllers/jobs/job/services/index.js new file mode 100644 index 00000000000..9cfe3bf1f0c --- /dev/null +++ b/ui/app/controllers/jobs/job/services/index.js @@ -0,0 +1,58 @@ +import Controller from '@ember/controller'; +import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; +import { alias } from '@ember/object/computed'; +import { computed } from '@ember/object'; +import { union } from '@ember/object/computed'; + +export default class JobsJobServicesIndexController extends Controller.extend( + WithNamespaceResetting +) { + @alias('model') job; + @alias('job.taskGroups') taskGroups; + + @computed('taskGroups.@each.tasks') + get tasks() { + return this.taskGroups.map((group) => group.tasks.toArray()).flat(); + } + + @computed('tasks.@each.services') + get taskServices() { + return this.tasks + .map((t) => (t.services || []).toArray()) + .flat() + .compact() + .map((service) => { + service.level = 'task'; + return service; + }); + } + + @computed('model.taskGroup.services.@each.name', 'taskGroups') + get groupServices() { + return this.taskGroups + .map((g) => (g.services || []).toArray()) + .flat() + .compact() + .map((service) => { + service.level = 'group'; + return service; + }); + } + + @union('taskServices', 'groupServices') serviceFragments; + + // Services, grouped by name, with aggregatable allocations. + @computed( + 'job.services.@each.{name,allocation}', + 'job.services.length', + 'serviceFragments' + ) + get services() { + return this.serviceFragments.map((fragment) => { + fragment.instances = this.job.services.filter( + (s) => s.name === fragment.name && s.derivedLevel === fragment.level + ); + return fragment; + }); + } +} diff --git a/ui/app/controllers/jobs/job/services/service.js b/ui/app/controllers/jobs/job/services/service.js new file mode 100644 index 00000000000..90cfb95c283 --- /dev/null +++ b/ui/app/controllers/jobs/job/services/service.js @@ -0,0 +1,13 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class JobsJobServicesServiceController extends Controller { + @service router; + queryParams = ['level']; + + @action + gotoAllocation(allocation) { + this.router.transitionTo('allocations.allocation', allocation.get('id')); + } +} diff --git a/ui/app/models/job.js b/ui/app/models/job.js index 80e8ff11443..eb8558f0794 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -141,6 +141,7 @@ export default class Job extends Model { @hasMany('variables') variables; @belongsTo('namespace') namespace; @belongsTo('job-scale') scaleState; + @hasMany('services') services; @hasMany('recommendation-summary') recommendationSummaries; diff --git a/ui/app/models/service-fragment.js b/ui/app/models/service-fragment.js new file mode 100644 index 00000000000..85141579473 --- /dev/null +++ b/ui/app/models/service-fragment.js @@ -0,0 +1,12 @@ +import { attr } from '@ember-data/model'; +import Fragment from 'ember-data-model-fragments/fragment'; +import { fragment } from 'ember-data-model-fragments/attributes'; + +export default class Service extends Fragment { + @attr('string') name; + @attr('string') portLabel; + @attr() tags; + @attr('string') onUpdate; + @attr('string') provider; + @fragment('consul-connect') connect; +} diff --git a/ui/app/models/service.js b/ui/app/models/service.js index 55bff86c192..1c8907579b3 100644 --- a/ui/app/models/service.js +++ b/ui/app/models/service.js @@ -1,11 +1,33 @@ -import { attr } from '@ember-data/model'; -import Fragment from 'ember-data-model-fragments/fragment'; -import { fragment } from 'ember-data-model-fragments/attributes'; +// @ts-check +import { attr, belongsTo } from '@ember-data/model'; +import Model from '@ember-data/model'; +import { alias } from '@ember/object/computed'; -export default class Service extends Fragment { - @attr('string') name; - @attr('string') portLabel; +export default class Service extends Model { + @belongsTo('allocation') allocation; + @belongsTo('job') job; + @belongsTo('node') node; + + @attr('string') address; + @attr('number') createIndex; + @attr('string') datacenter; + @attr('number') modifyIndex; + @attr('string') namespace; + @attr('number') port; + @attr('string') serviceName; @attr() tags; - @attr('string') onUpdate; - @fragment('consul-connect') connect; + + @alias('serviceName') name; + + // Services can exist at either Group or Task level. + // While our endpoints to get them do not explicitly tell us this, + // we can infer it from the service's ID: + get derivedLevel() { + const idWithoutServiceName = this.id.replace(this.serviceName, ''); + if (idWithoutServiceName.includes('group-')) { + return 'group'; + } else { + return 'task'; + } + } } diff --git a/ui/app/models/task-group.js b/ui/app/models/task-group.js index 8d296200259..8b246590734 100644 --- a/ui/app/models/task-group.js +++ b/ui/app/models/task-group.js @@ -35,7 +35,7 @@ export default class TaskGroup extends Fragment { @fragmentArray('task') tasks; - @fragmentArray('service') services; + @fragmentArray('service-fragment') services; @fragmentArray('volume-definition') volumes; diff --git a/ui/app/models/task.js b/ui/app/models/task.js index e84603b7aea..a2b9c9642d8 100644 --- a/ui/app/models/task.js +++ b/ui/app/models/task.js @@ -48,7 +48,7 @@ export default class Task extends Fragment { @attr('number') reservedCPU; @attr('number') reservedDisk; @attr('number') reservedEphemeralDisk; - @fragmentArray('service') services; + @fragmentArray('service-fragment') services; @fragmentArray('volume-mount', { defaultValue: () => [] }) volumeMounts; diff --git a/ui/app/router.js b/ui/app/router.js index dbedd67dbea..9ec65900125 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -24,6 +24,9 @@ Router.map(function () { this.route('evaluations'); this.route('allocations'); this.route('clients'); + this.route('services', function () { + this.route('service', { path: '/:name' }); + }); }); }); diff --git a/ui/app/routes/jobs/job/services.js b/ui/app/routes/jobs/job/services.js new file mode 100644 index 00000000000..9ceadb5b1ee --- /dev/null +++ b/ui/app/routes/jobs/job/services.js @@ -0,0 +1,26 @@ +import Route from '@ember/routing/route'; +import WithWatchers from 'nomad-ui/mixins/with-watchers'; +import { collect } from '@ember/object/computed'; +import { + watchRecord, + watchRelationship, +} from 'nomad-ui/utils/properties/watch'; + +export default class JobsJobServicesRoute extends Route.extend(WithWatchers) { + model() { + const job = this.modelFor('jobs.job'); + return job && job.get('services').then(() => job); + } + + startWatchers(controller, model) { + if (model) { + controller.set('watchServices', this.watchServices.perform(model)); + controller.set('watchJob', this.watchJob.perform(model)); + } + } + + @watchRelationship('services', true) watchServices; + @watchRecord('job') watchJob; + + @collect('watchServices', 'watchJob') watchers; +} diff --git a/ui/app/routes/jobs/job/services/index.js b/ui/app/routes/jobs/job/services/index.js new file mode 100644 index 00000000000..57a56d12966 --- /dev/null +++ b/ui/app/routes/jobs/job/services/index.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class JobsJobServicesIndexRoute extends Route {} diff --git a/ui/app/routes/jobs/job/services/service.js b/ui/app/routes/jobs/job/services/service.js new file mode 100644 index 00000000000..0722b761afb --- /dev/null +++ b/ui/app/routes/jobs/job/services/service.js @@ -0,0 +1,12 @@ +import Route from '@ember/routing/route'; + +export default class JobsJobServicesServiceRoute extends Route { + model({ name = '', level = '' }) { + const services = this.modelFor('jobs.job') + .get('services') + .filter( + (service) => service.name === name && service.derivedLevel === level + ); + return { name, instances: services || [] }; + } +} diff --git a/ui/app/serializers/job.js b/ui/app/serializers/job.js index a21f8f8ebe0..c50440936eb 100644 --- a/ui/app/serializers/job.js +++ b/ui/app/serializers/job.js @@ -101,6 +101,11 @@ export default class JobSerializer extends ApplicationSerializer { related: buildURL(`${jobURL}/evaluations`, { namespace }), }, }, + services: { + links: { + related: buildURL(`${jobURL}/services`, { namespace }), + }, + }, variables: { links: { related: buildURL(`/${apiNamespace}/vars`, { diff --git a/ui/app/serializers/service-fragment.js b/ui/app/serializers/service-fragment.js new file mode 100644 index 00000000000..bd74d87e5e1 --- /dev/null +++ b/ui/app/serializers/service-fragment.js @@ -0,0 +1,11 @@ +import ApplicationSerializer from './application'; +import classic from 'ember-classic-decorator'; + +@classic +export default class ServiceFragmentSerializer extends ApplicationSerializer { + attrs = { + connect: 'Connect', + }; + + arrayNullOverrides = ['Tags']; +} diff --git a/ui/app/serializers/service.js b/ui/app/serializers/service.js index 89a5588a421..a7af21eaaad 100644 --- a/ui/app/serializers/service.js +++ b/ui/app/serializers/service.js @@ -1,11 +1,11 @@ -import ApplicationSerializer from './application'; import classic from 'ember-classic-decorator'; +import ApplicationSerializer from './application'; @classic export default class ServiceSerializer extends ApplicationSerializer { - attrs = { - connect: 'Connect', - }; - - arrayNullOverrides = ['Tags']; + normalize(typeHash, hash) { + hash.AllocationID = hash.AllocID; // TODO: keyForRelationship maybe? + hash.JobID = JSON.stringify([hash.JobID, hash.Namespace]); + return super.normalize(typeHash, hash); + } } diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss index df5f3c02160..ef5f3fbc125 100644 --- a/ui/app/styles/components.scss +++ b/ui/app/styles/components.scss @@ -48,3 +48,4 @@ @import './components/evaluations'; @import './components/variables'; @import './components/keyboard-shortcuts-modal'; +@import './components/services'; diff --git a/ui/app/styles/components/services.scss b/ui/app/styles/components/services.scss new file mode 100644 index 00000000000..e3ed84d9c9f --- /dev/null +++ b/ui/app/styles/components/services.scss @@ -0,0 +1,10 @@ +.service-list { + .title { + .back-link { + text-decoration: none; + color: #363636; + position: relative; + top: 4px; + } + } +} \ No newline at end of file diff --git a/ui/app/templates/components/job-service-row.hbs b/ui/app/templates/components/job-service-row.hbs new file mode 100644 index 00000000000..6e57e314062 --- /dev/null +++ b/ui/app/templates/components/job-service-row.hbs @@ -0,0 +1,39 @@ + + + {{#if (eq @service.provider "nomad")}} + + {{else if (eq @service.provider "consul")}} + + {{/if}} + + + {{#if (eq @service.provider "nomad")}} + {{@service.name}} + {{else}} + {{@service.name}} + {{/if}} + + + {{@service.level}} + + + {{@service.instances.0.node.name}} + + + {{#each @service.tags as |tag|}} + {{tag}} + {{/each}} + + + {{#if (eq @service.provider "nomad")}} + {{@service.instances.length}} {{pluralize "allocation" @service.instances.length}} + {{else}} + -- + {{/if}} + + diff --git a/ui/app/templates/components/job-subnav.hbs b/ui/app/templates/components/job-subnav.hbs index 2851b3bfc84..4216996fa48 100644 --- a/ui/app/templates/components/job-subnav.hbs +++ b/ui/app/templates/components/job-subnav.hbs @@ -68,5 +68,14 @@ {{/if}} +
  • + + Services + +
  • \ No newline at end of file diff --git a/ui/app/templates/jobs/job/services.hbs b/ui/app/templates/jobs/job/services.hbs new file mode 100644 index 00000000000..826c25d0543 --- /dev/null +++ b/ui/app/templates/jobs/job/services.hbs @@ -0,0 +1,3 @@ +{{page-title "Job " @model.name " services"}} + +{{outlet}} diff --git a/ui/app/templates/jobs/job/services/index.hbs b/ui/app/templates/jobs/job/services/index.hbs new file mode 100644 index 00000000000..c0a4a8e0aa0 --- /dev/null +++ b/ui/app/templates/jobs/job/services/index.hbs @@ -0,0 +1,39 @@ +
    + {{#if this.services.length}} + + + + Name + Level + Client + Tags + Number of Alocations + + + + + + {{else}} +
    +
    +

    + No Services +

    +

    + No services running on {{this.job.name}}. +

    +
    +
    + {{/if}} +
    \ No newline at end of file diff --git a/ui/app/templates/jobs/job/services/service.hbs b/ui/app/templates/jobs/job/services/service.hbs new file mode 100644 index 00000000000..75254fa8deb --- /dev/null +++ b/ui/app/templates/jobs/job/services/service.hbs @@ -0,0 +1,39 @@ +
    +

    + + + + {{this.model.name}} +

    + + + + Allocation + IP Address & Port + + + + + {{row.model.allocation.shortId}} + + + {{row.model.address}}:{{row.model.port}} + + + + +
    \ No newline at end of file diff --git a/ui/app/utils/properties/watch.js b/ui/app/utils/properties/watch.js index 08dc18842ec..11b4e19dbab 100644 --- a/ui/app/utils/properties/watch.js +++ b/ui/app/utils/properties/watch.js @@ -39,7 +39,7 @@ export function watchRecord(modelName) { }).drop(); } -export function watchRelationship(relationshipName) { +export function watchRelationship(relationshipName, replace = false) { return task(function* (model, throttle = 2000) { assert( 'To watch a relationship, the adapter of the model provided to the watchRelationship task MUST extend Watchable', @@ -54,6 +54,7 @@ export function watchRelationship(relationshipName) { .reloadRelationship(model, relationshipName, { watch: true, abortController: controller, + replace, }), wait(throttle), ]); diff --git a/ui/mirage/config.js b/ui/mirage/config.js index c7358208439..66f98e3c6ca 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -884,6 +884,15 @@ export default function () { }); //#endregion Variables + + //#region Services + + this.get('/job/:id/services', function (schema, { params }) { + const { services } = schema; + return this.serialize(services.where({ jobId: params.id })); + }); + + //#endregion Services } function filterKeys(object, ...keys) { diff --git a/ui/tests/acceptance/keyboard-test.js b/ui/tests/acceptance/keyboard-test.js index c2d3220215d..1e208a5d01b 100644 --- a/ui/tests/acceptance/keyboard-test.js +++ b/ui/tests/acceptance/keyboard-test.js @@ -337,6 +337,15 @@ module('Acceptance | keyboard', function (hooks) { 'Shift+ArrowRight takes you to the next tab (Evaluations)' ); + await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', { + shiftKey: true, + }); + assert.equal( + currentURL(), + `/jobs/${jobID}@default/services`, + 'Shift+ArrowRight takes you to the next tab (Services)' + ); + await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', { shiftKey: true, }); diff --git a/ui/tests/pages/jobs/job/services.js b/ui/tests/pages/jobs/job/services.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ui/tests/unit/adapters/service-test.js b/ui/tests/unit/adapters/service-test.js new file mode 100644 index 00000000000..546125fbca2 --- /dev/null +++ b/ui/tests/unit/adapters/service-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Adapter | service', function (hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function (assert) { + let adapter = this.owner.lookup('adapter:service'); + assert.ok(adapter); + }); +}); diff --git a/ui/tests/unit/controllers/jobs/job/services-test.js b/ui/tests/unit/controllers/jobs/job/services-test.js new file mode 100644 index 00000000000..d2c4a6a5eb5 --- /dev/null +++ b/ui/tests/unit/controllers/jobs/job/services-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Controller | jobs/job/services', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let controller = this.owner.lookup('controller:jobs/job/services'); + assert.ok(controller); + }); +}); diff --git a/ui/tests/unit/controllers/jobs/job/services/index-test.js b/ui/tests/unit/controllers/jobs/job/services/index-test.js new file mode 100644 index 00000000000..4626d98b5ac --- /dev/null +++ b/ui/tests/unit/controllers/jobs/job/services/index-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Controller | jobs/job/services/index', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let controller = this.owner.lookup('controller:jobs/job/services/index'); + assert.ok(controller); + }); +}); diff --git a/ui/tests/unit/controllers/jobs/job/services/service-test.js b/ui/tests/unit/controllers/jobs/job/services/service-test.js new file mode 100644 index 00000000000..2f91af57ec7 --- /dev/null +++ b/ui/tests/unit/controllers/jobs/job/services/service-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Controller | jobs/job/services/service', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let controller = this.owner.lookup('controller:jobs/job/services/service'); + assert.ok(controller); + }); +}); diff --git a/ui/tests/unit/models/service-fragment-test.js b/ui/tests/unit/models/service-fragment-test.js new file mode 100644 index 00000000000..3d7b44c34cd --- /dev/null +++ b/ui/tests/unit/models/service-fragment-test.js @@ -0,0 +1,13 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Model | service fragment', function (hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function (assert) { + let store = this.owner.lookup('service:store'); + let model = store.createRecord('service-fragment', {}); + assert.ok(model); + }); +}); diff --git a/ui/tests/unit/routes/jobs/job/services/index-test.js b/ui/tests/unit/routes/jobs/job/services/index-test.js new file mode 100644 index 00000000000..a86fc3839f2 --- /dev/null +++ b/ui/tests/unit/routes/jobs/job/services/index-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | jobs/job/services/index', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup('route:jobs/job/services/index'); + assert.ok(route); + }); +}); diff --git a/ui/tests/unit/routes/jobs/job/services/service-test.js b/ui/tests/unit/routes/jobs/job/services/service-test.js new file mode 100644 index 00000000000..69b3d61023d --- /dev/null +++ b/ui/tests/unit/routes/jobs/job/services/service-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Route | jobs/job/services/service', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup('route:jobs/job/services/service'); + assert.ok(route); + }); +}); diff --git a/ui/tests/unit/serializers/service-test.js b/ui/tests/unit/serializers/service-test.js new file mode 100644 index 00000000000..cbf6080491c --- /dev/null +++ b/ui/tests/unit/serializers/service-test.js @@ -0,0 +1,23 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +module('Unit | Serializer | service', function (hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test('it exists', function (assert) { + let store = this.owner.lookup('service:store'); + let serializer = store.serializerFor('service'); + + assert.ok(serializer); + }); + + test('it serializes records', function (assert) { + let store = this.owner.lookup('service:store'); + let record = store.createRecord('service', {}); + + let serializedRecord = record.serialize(); + + assert.ok(serializedRecord); + }); +});