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