diff --git a/ui/app/components/allocation-service-sidebar.hbs b/ui/app/components/allocation-service-sidebar.hbs new file mode 100644 index 000000000000..ada404772305 --- /dev/null +++ b/ui/app/components/allocation-service-sidebar.hbs @@ -0,0 +1,130 @@ + diff --git a/ui/app/components/allocation-service-sidebar.js b/ui/app/components/allocation-service-sidebar.js new file mode 100644 index 000000000000..a66d3ff622c8 --- /dev/null +++ b/ui/app/components/allocation-service-sidebar.js @@ -0,0 +1,41 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +export default class AllocationServiceSidebarComponent extends Component { + @service store; + + get isSideBarOpen() { + return !!this.args.service; + } + keyCommands = [ + { + label: 'Close Evaluations Sidebar', + pattern: ['Escape'], + action: () => this.args.fns.closeSidebar(), + }, + ]; + + get service() { + return this.store.query('service-fragment', { refID: this.args.serviceID }); + } + + get address() { + const port = this.args.allocation?.allocatedResources?.ports?.findBy( + 'label', + this.args.service.portLabel + ); + if (port) { + return `${port.hostIp}:${port.value}`; + } else { + return null; + } + } + + get aggregateStatus() { + return this.args.service?.mostRecentChecks?.any( + (check) => check.Status === 'failure' + ) + ? 'Unhealthy' + : 'Healthy'; + } +} diff --git a/ui/app/components/service-status-bar.js b/ui/app/components/service-status-bar.js index 3864ca74604d..fda824a67ccf 100644 --- a/ui/app/components/service-status-bar.js +++ b/ui/app/components/service-status-bar.js @@ -8,22 +8,19 @@ import classic from 'ember-classic-decorator'; export default class ServiceStatusBar extends DistributionBar { layoutName = 'components/distribution-bar'; - services = null; - name = null; + status = null; 'data-test-service-status-bar' = true; - @computed('services.{}', 'name') + @computed('status.{failure,pending,success}') get data() { - const service = this.services && this.services.get(this.name); - - if (!service) { + if (!this.status) { return []; } - const pending = service.pending || 0; - const failing = service.failure || 0; - const success = service.success || 0; + const pending = this.status.pending || 0; + const failing = this.status.failure || 0; + const success = this.status.success || 0; const [grey, red, green] = ['queued', 'failed', 'complete']; diff --git a/ui/app/controllers/allocations/allocation/index.js b/ui/app/controllers/allocations/allocation/index.js index 7e6d38552f7e..d3e4d5a9e32e 100644 --- a/ui/app/controllers/allocations/allocation/index.js +++ b/ui/app/controllers/allocations/allocation/index.js @@ -12,6 +12,7 @@ import { watchRecord } from 'nomad-ui/utils/properties/watch'; import messageForError from 'nomad-ui/utils/message-from-adapter-error'; import classic from 'ember-classic-decorator'; import { union } from '@ember/object/computed'; +import { tracked } from '@glimmer/tracking'; @classic export default class IndexController extends Controller.extend(Sortable) { @@ -25,6 +26,9 @@ export default class IndexController extends Controller.extend(Sortable) { { sortDescending: 'desc', }, + { + activeServiceID: 'service', + }, ]; sortProperty = 'name'; @@ -55,7 +59,7 @@ export default class IndexController extends Controller.extend(Sortable) { @computed('tasks.@each.services') get taskServices() { return this.get('tasks') - .map((t) => ((t && t.get('services')) || []).toArray()) + .map((t) => ((t && t.services) || []).toArray()) .flat() .compact(); } @@ -67,38 +71,33 @@ export default class IndexController extends Controller.extend(Sortable) { @union('taskServices', 'groupServices') services; - @computed('model.healthChecks.{}') - get serviceHealthStatuses() { - if (!this.model.healthChecks) return null; - - let result = new Map(); - Object.values(this.model.healthChecks)?.forEach((service) => { - const isTask = !!service.Task; - const groupName = service.Group.split('.')[1].split('[')[0]; - const currentServiceStatus = service.Status; - - const currentServiceName = isTask - ? service.Task.concat(`-${service.Service}`) - : groupName.concat(`-${service.Service}`); - const serviceStatuses = result.get(currentServiceName); - if (serviceStatuses) { - if (serviceStatuses[currentServiceStatus]) { - result.set(currentServiceName, { - ...serviceStatuses, - [currentServiceStatus]: serviceStatuses[currentServiceStatus]++, - }); - } else { - result.set(currentServiceName, { - ...serviceStatuses, - [currentServiceStatus]: 1, - }); - } - } else { - result.set(currentServiceName, { [currentServiceStatus]: 1 }); + @computed('model.healthChecks.{}', 'services') + get servicesWithHealthChecks() { + return this.services.map((service) => { + if (this.model.healthChecks) { + const healthChecks = Object.values(this.model.healthChecks)?.filter( + (check) => { + const refPrefix = + check.Task || check.Group.split('.')[1].split('[')[0]; + const currentServiceName = `${refPrefix}-${check.Service}`; + return currentServiceName === service.refID; + } + ); + // Only append those healthchecks whose timestamps are not already found in service.healthChecks + healthChecks.forEach((check) => { + if ( + !service.healthChecks.find( + (sc) => + sc.Check === check.Check && sc.Timestamp === check.Timestamp + ) + ) { + service.healthChecks.pushObject(check); + service.healthChecks = [...service.healthChecks.slice(-10)]; + } + }); } + return service; }); - - return result; } onDismiss() { @@ -165,4 +164,31 @@ export default class IndexController extends Controller.extend(Sortable) { taskClick(allocation, task, event) { lazyClick([() => this.send('gotoTask', allocation, task), event]); } + + //#region Services + + @tracked activeServiceID = null; + + @action handleServiceClick(service) { + this.set('activeServiceID', service.refID); + } + + @computed('activeServiceID', 'services') + get activeService() { + return this.services.findBy('refID', this.activeServiceID); + } + + @action closeSidebar() { + this.set('activeServiceID', null); + } + + keyCommands = [ + { + label: 'Close Evaluations Sidebar', + pattern: ['Escape'], + action: () => this.closeSidebar(), + }, + ]; + + //#endregion Services } diff --git a/ui/app/models/service-fragment.js b/ui/app/models/service-fragment.js index 319ca8f42cb0..0d3e288c7348 100644 --- a/ui/app/models/service-fragment.js +++ b/ui/app/models/service-fragment.js @@ -1,7 +1,10 @@ import { attr } from '@ember-data/model'; import Fragment from 'ember-data-model-fragments/fragment'; import { fragment } from 'ember-data-model-fragments/attributes'; +import { computed } from '@ember/object'; +import classic from 'ember-classic-decorator'; +@classic export default class Service extends Fragment { @attr('string') name; @attr('string') portLabel; @@ -11,8 +14,34 @@ export default class Service extends Fragment { @fragment('consul-connect') connect; @attr() groupName; @attr() taskName; - get refID() { return `${this.groupName || this.taskName}-${this.name}`; } + @attr({ defaultValue: () => [] }) healthChecks; + + @computed('healthChecks.[]') + get mostRecentChecks() { + // Get unique check names, then get the most recent one + return this.get('healthChecks') + .mapBy('Check') + .uniq() + .map((name) => { + return this.get('healthChecks') + .sortBy('Timestamp') + .reverse() + .find((x) => x.Check === name); + }) + .sortBy('Check'); + } + + @computed('mostRecentChecks.[]') + get mostRecentCheckStatus() { + // Get unique check names, then get the most recent one + return this.get('mostRecentChecks') + .mapBy('Status') + .reduce((acc, curr) => { + acc[curr] = (acc[curr] || 0) + 1; + return acc; + }, {}); + } } diff --git a/ui/app/serializers/task-group.js b/ui/app/serializers/task-group.js index 82f792952417..b98b5cfefbcf 100644 --- a/ui/app/serializers/task-group.js +++ b/ui/app/serializers/task-group.js @@ -13,7 +13,6 @@ export default class TaskGroup extends ApplicationSerializer { service.GroupName = hash.Name; }); } - // Provide EphemeralDisk to each task hash.Tasks.forEach((task) => { task.EphemeralDisk = copy(hash.EphemeralDisk); diff --git a/ui/app/styles/components/services.scss b/ui/app/styles/components/services.scss index 884786d872df..7a3c7ab3a39d 100644 --- a/ui/app/styles/components/services.scss +++ b/ui/app/styles/components/services.scss @@ -13,3 +13,57 @@ margin-right: 5px; } } + +.service-sidebar { + .aggregate-status { + font-size: 1rem; + font-weight: normal; + line-height: 16px; + & > svg { + position: relative; + top: 3px; + margin-left: 5px; + } + } + td.name { + width: 100px; + span { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100px; + } + } + td.status { + span { + display: inline-grid; + grid-auto-flow: column; + line-height: 16px; + gap: 0.25rem; + } + } + + td.service-output { + padding: 0; + code { + padding: 1.25em 1.5em; + max-height: 100px; + overflow: auto; + display: block; + } + } + + .inline-definitions { + display: grid; + grid-template-columns: auto 1fr; + } +} + +.allocation-services-table { + td svg { + position: relative; + top: 3px; + margin-right: 5px; + } +} diff --git a/ui/app/styles/components/sidebar.scss b/ui/app/styles/components/sidebar.scss index 3847d933d27d..357a42fd21a1 100644 --- a/ui/app/styles/components/sidebar.scss +++ b/ui/app/styles/components/sidebar.scss @@ -1,3 +1,6 @@ +$topNavOffset: 112px; +$subNavOffset: 49px; + .sidebar { position: fixed; background: #ffffff; @@ -6,14 +9,17 @@ right: 0%; overflow-y: auto; bottom: 0; - top: 112px; + top: $topNavOffset; transform: translateX(100%); transition-duration: 150ms; transition-timing-function: ease; - box-shadow: 6px 6px rgba(0, 0, 0, 0.06), 0px 12px 16px rgba(0, 0, 0, 0.2); z-index: $z-modal; &.open { transform: translateX(0%); + box-shadow: 6px 6px rgba(0, 0, 0, 0.06), 0px 12px 16px rgba(0, 0, 0, 0.2); + } + &.has-subnav { + top: calc($topNavOffset + $subNavOffset); } } diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs index 437de69e6c1b..7bade966f144 100644 --- a/ui/app/templates/allocations/allocation/index.hbs +++ b/ui/app/templates/allocations/allocation/index.hbs @@ -270,62 +270,49 @@ Services
- + - + Name - + Port Tags - - On Update - - - Connect? - - - Upstreams - Health Check Status - + + {{#if (eq row.model.provider "nomad")}} + + {{else}} + + {{/if}} {{row.model.name}} {{row.model.portLabel}} - {{join ", " row.model.tags}} - - - {{row.model.onUpdate}} - - - {{if row.model.connect "Yes" "No"}} - - - {{#each - row.model.connect.sidecarService.proxy.upstreams as |upstream| - }} - {{upstream.destinationName}}:{{upstream.localBindPort}} + {{#each row.model.tags as |tag|}} + {{tag}} {{/each}} {{#if (eq row.model.provider "nomad")}}
- {{#if (is-empty row.model.taskName)}} - - {{else}} - - {{/if}} +
{{/if}} @@ -497,4 +484,11 @@
{{/if}} + \ No newline at end of file diff --git a/ui/mirage/config.js b/ui/mirage/config.js index 66f98e3c6ca8..eff6200ad22b 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -887,11 +887,43 @@ export default function () { //#region Services + const allocationServiceChecksHandler = function (schema) { + let disasters = [ + "Moon's haunted", + 'reticulating splines', + 'The operation completed unexpectedly', + 'Ran out of sriracha :(', + '¯\\_(ツ)_/¯', + '\n\n \n \n Error response\n \n \n

Error response

\n

Error code: 404

\n

Message: File not found.

\n

Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.

\n \n\n', + ]; + let fakeChecks = []; + schema.serviceFragments.all().models.forEach((frag, iter) => { + [...Array(iter)].forEach((check, checkIter) => { + const checkOK = faker.random.boolean(); + fakeChecks.push({ + Check: `check-${checkIter}`, + Group: `job-name.${frag.taskGroup?.name}[1]`, + Output: checkOK + ? 'nomad: http ok' + : disasters[Math.floor(Math.random() * disasters.length)], + Service: frag.name, + Status: checkOK ? 'success' : 'failure', + StatusCode: checkOK ? 200 : 400, + Task: frag.task?.name, + Timestamp: new Date().getTime(), + }); + }); + }); + return fakeChecks; + }; + this.get('/job/:id/services', function (schema, { params }) { const { services } = schema; return this.serialize(services.where({ jobId: params.id })); }); + this.get('/client/allocation/:id/checks', allocationServiceChecksHandler); + //#endregion Services } diff --git a/ui/mirage/factories/task.js b/ui/mirage/factories/task.js index d31d3f167d9f..33a6d84f1d01 100644 --- a/ui/mirage/factories/task.js +++ b/ui/mirage/factories/task.js @@ -75,11 +75,13 @@ export default Factory.extend({ if (task.withServices) { const services = server.createList('service-fragment', 1, { provider: 'nomad', + taskName: task.name, }); services.push( server.create('service-fragment', { provider: 'consul', + taskName: task.name, }) ); services.forEach((fragment) => { diff --git a/ui/tests/acceptance/allocation-detail-test.js b/ui/tests/acceptance/allocation-detail-test.js index d45995759aae..a85fb13ce681 100644 --- a/ui/tests/acceptance/allocation-detail-test.js +++ b/ui/tests/acceptance/allocation-detail-test.js @@ -1,7 +1,7 @@ /* eslint-disable qunit/require-expect */ /* Mirage fixtures are random so we can't expect a set number of assertions */ import { run } from '@ember/runloop'; -import { currentURL } from '@ember/test-helpers'; +import { currentURL, click, visit, triggerEvent } from '@ember/test-helpers'; import { assign } from '@ember/polyfills'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; @@ -324,22 +324,7 @@ module('Acceptance | allocation detail', function (hooks) { assert.equal(renderedService.name, serverService.name); assert.equal(renderedService.port, serverService.portLabel); - assert.equal(renderedService.onUpdate, serverService.onUpdate); - assert.equal(renderedService.tags, (serverService.tags || []).join(', ')); - - assert.equal( - renderedService.connect, - serverService.Connect ? 'Yes' : 'No' - ); - - const upstreams = serverService.Connect.SidecarService.Proxy.Upstreams; - const serverUpstreamsString = upstreams - .map( - (upstream) => `${upstream.DestinationName}:${upstream.LocalBindPort}` - ) - .join(' '); - - assert.equal(renderedService.upstreams, serverUpstreamsString); + assert.equal(renderedService.tags, (serverService.tags || []).join(' ')); }); }); @@ -632,3 +617,80 @@ module('Acceptance | allocation detail (preemptions)', function (hooks) { assert.ok(Allocation.wasPreempted, 'Preempted allocation section is shown'); }); }); + +module('Acceptance | allocation detail (services)', function (hooks) { + setupApplicationTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(async function () { + server.create('feature', { name: 'Dynamic Application Sizing' }); + server.createList('agent', 3, 'withConsulLink', 'withVaultLink'); + server.createList('node', 5); + server.createList('job', 1, { createRecommendations: true }); + server.create('job', { + withGroupServices: true, + withTaskServices: true, + name: 'Service-haver', + id: 'service-haver', + namespaceId: 'default', + }); + + server.db.serviceFragments.update({ + healthChecks: [ + { + Status: 'success', + Check: 'check1', + Timestamp: 99, + }, + { + Status: 'failure', + Check: 'check2', + Output: 'One', + propThatDoesntMatter: + 'this object will be ignored, since it shared a Check name with a later one.', + Timestamp: 98, + }, + { + Status: 'success', + Check: 'check2', + Output: 'Two', + Timestamp: 99, + }, + { + Status: 'failure', + Check: 'check3', + Output: 'Oh no!', + Timestamp: 99, + }, + ], + }); + }); + + test('Allocation has a list of services with active checks', async function (assert) { + await visit('jobs/service-haver@default'); + await click('.allocation-row'); + + assert.dom('[data-test-service]').exists(); + assert.dom('.service-sidebar').exists(); + assert.dom('.service-sidebar').doesNotHaveClass('open'); + assert + .dom('[data-test-service-status-bar]') + .exists('At least one allocation has service health'); + await click('[data-test-service-status-bar]'); + assert.dom('.service-sidebar').hasClass('open'); + assert + .dom('table.health-checks tr[data-service-health="success"]') + .exists({ count: 2 }, 'Two successful health checks'); + assert + .dom('table.health-checks tr[data-service-health="failure"]') + .exists({ count: 1 }, 'One failing health check'); + assert + .dom( + 'table.health-checks tr[data-service-health="failure"] td.service-output' + ) + .containsText('Oh no!'); + + await triggerEvent('.page-layout', 'keydown', { key: 'Escape' }); + assert.dom('.service-sidebar').doesNotHaveClass('open'); + }); +}); diff --git a/ui/tests/integration/components/allocation-service-sidebar-test.js b/ui/tests/integration/components/allocation-service-sidebar-test.js new file mode 100644 index 000000000000..142e2b81ae00 --- /dev/null +++ b/ui/tests/integration/components/allocation-service-sidebar-test.js @@ -0,0 +1,38 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { click, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit'; + +module( + 'Integration | Component | allocation-service-sidebar', + function (hooks) { + setupRenderingTest(hooks); + + test('it supports basic open/close states', async function (assert) { + assert.expect(7); + await componentA11yAudit(this.element, assert); + + this.set('closeSidebar', () => this.set('service', null)); + + this.set('service', { name: 'Funky Service' }); + await render( + hbs`` + ); + assert.dom('h1').includesText('Funky Service'); + assert.dom('.sidebar').hasClass('open'); + + this.set('service', null); + await render( + hbs`` + ); + assert.dom(this.element).hasText(''); + assert.dom('.sidebar').doesNotHaveClass('open'); + + this.set('service', { name: 'Funky Service' }); + await click('[data-test-close-service-sidebar]'); + assert.dom(this.element).hasText(''); + assert.dom('.sidebar').doesNotHaveClass('open'); + }); + } +); diff --git a/ui/tests/integration/components/service-status-bar-test.js b/ui/tests/integration/components/service-status-bar-test.js index c773316a02c7..83478696fad5 100644 --- a/ui/tests/integration/components/service-status-bar-test.js +++ b/ui/tests/integration/components/service-status-bar-test.js @@ -12,29 +12,18 @@ module('Integration | Component | Service Status Bar', function (hooks) { const component = this; await componentA11yAudit(component, assert); - const healthyService = { + const serviceStatus = { success: 1, - }; - - const failingService = { - failure: 1, - }; - - const pendingService = { pending: 1, + failure: 1, }; - const services = new Map(); - services.set('peter', healthyService); - services.set('peter', { ...services.get('peter'), ...failingService }); - services.set('peter', { ...services.get('peter'), ...pendingService }); - - this.set('services', services); + this.set('serviceStatus', serviceStatus); await render(hbs`
diff --git a/ui/tests/unit/controllers/allocations/allocation/index-test.js b/ui/tests/unit/controllers/allocations/allocation/index-test.js index ae45d5f7baaf..c68f70f39c00 100644 --- a/ui/tests/unit/controllers/allocations/allocation/index-test.js +++ b/ui/tests/unit/controllers/allocations/allocation/index-test.js @@ -12,39 +12,86 @@ module('Unit | Controller | allocations/allocation/index', function (hooks) { controller.set('model', Allocation); - const result = new Map(); - result.set('fakepy-fake-py', { - failure: 1, - success: 1, - }); - result.set('http.server-task-fake-py', { - failure: 1, - success: 1, - }); - result.set('http.server-web', { - success: 1, - }); + const groupFakePy = { + refID: 'fakepy-group-fake-py', + statuses: { + success: 1, + failure: 1, + pending: 0, + }, + }; + const taskFakePy = { + refID: 'http.server-task-fake-py', + statuses: { + success: 2, + failure: 2, + pending: 0, + }, + }; + const pender = { + refID: 'http.server-pender', + statuses: { + success: 0, + failure: 0, + pending: 1, + }, + }; - const fakePy = controller.serviceHealthStatuses.get('fakepy-fake-py'); - const taskFakePy = controller.serviceHealthStatuses.get( - 'http.server-task-fake-py' + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', groupFakePy.refID) + .healthChecks.filter((check) => check.Status === 'success').length, + groupFakePy.statuses['success'] + ); + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', groupFakePy.refID) + .healthChecks.filter((check) => check.Status === 'failure').length, + groupFakePy.statuses['failure'] + ); + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', groupFakePy.refID) + .healthChecks.filter((check) => check.Status === 'pending').length, + groupFakePy.statuses['pending'] + ); + + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', taskFakePy.refID) + .healthChecks.filter((check) => check.Status === 'success').length, + taskFakePy.statuses['success'] + ); + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', taskFakePy.refID) + .healthChecks.filter((check) => check.Status === 'failure').length, + taskFakePy.statuses['failure'] + ); + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', taskFakePy.refID) + .healthChecks.filter((check) => check.Status === 'pending').length, + taskFakePy.statuses['pending'] ); - const web = controller.serviceHealthStatuses.get('http.server-web'); - assert.deepEqual( - fakePy, - result.get('fakepy-fake-py'), - 'Service Health Check data is transformed and grouped by Service name' + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', pender.refID) + .healthChecks.filter((check) => check.Status === 'success').length, + pender.statuses['success'] ); - assert.deepEqual( - taskFakePy, - result.get('http.server-task-fake-py'), - 'Service Health Check data is transformed and grouped by Service name' + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', pender.refID) + .healthChecks.filter((check) => check.Status === 'failure').length, + pender.statuses['failure'] ); - assert.deepEqual( - web, - result.get('http.server-web'), - 'Service Health Check data is transformed and grouped by Service name' + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', pender.refID) + .healthChecks.filter((check) => check.Status === 'pending').length, + pender.statuses['pending'] ); }); @@ -53,50 +100,61 @@ module('Unit | Controller | allocations/allocation/index', function (hooks) { 'controller:allocations/allocation/index' ); - const dupeTaskLevelService = - Allocation.allocationTaskGroup.Tasks[0].Services[0]; - dupeTaskLevelService.Name = 'fake-py'; - dupeTaskLevelService.isTaskLevel = true; - - const healthChecks = Allocation.healthChecks; - healthChecks['73ad9b936fb3f3cc4d7f62a1aab6de53'].Service = 'fake-py'; - healthChecks['19421ef816ae0d3eeeb81697bce0e261'].Service = 'fake-py'; - controller.set('model', Allocation); - const result = new Map(); - result.set('fakepy-fake-py', { - failure: 1, - success: 1, - }); - result.set('http.server-fake-py', { - failure: 1, - success: 1, - }); - result.set('http.server-web', { - success: 1, - }); + const groupDupe = { + refID: 'fakepy-duper', + statuses: { + success: 1, + failure: 0, + pending: 0, + }, + }; + const taskDupe = { + refID: 'http.server-duper', + statuses: { + success: 0, + failure: 1, + pending: 0, + }, + }; - const fakePy = controller.serviceHealthStatuses.get('fakepy-fake-py'); - const taskFakePy = controller.serviceHealthStatuses.get( - 'http.server-fake-py' + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', groupDupe.refID) + .healthChecks.filter((check) => check.Status === 'success').length, + groupDupe.statuses['success'] + ); + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', groupDupe.refID) + .healthChecks.filter((check) => check.Status === 'failure').length, + groupDupe.statuses['failure'] + ); + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', groupDupe.refID) + .healthChecks.filter((check) => check.Status === 'pending').length, + groupDupe.statuses['pending'] ); - const web = controller.serviceHealthStatuses.get('http.server-web'); - assert.deepEqual( - fakePy, - result.get('fakepy-fake-py'), - 'Service Health Check data is transformed and grouped by Service name' + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', taskDupe.refID) + .healthChecks.filter((check) => check.Status === 'success').length, + taskDupe.statuses['success'] ); - assert.deepEqual( - taskFakePy, - result.get('http.server-fake-py'), - 'Service Health Check data is transformed and grouped by Service name' + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', taskDupe.refID) + .healthChecks.filter((check) => check.Status === 'failure').length, + taskDupe.statuses['failure'] ); - assert.deepEqual( - web, - result.get('http.server-web'), - 'Service Health Check data is transformed and grouped by Service name' + assert.equal( + controller.servicesWithHealthChecks + .findBy('refID', taskDupe.refID) + .healthChecks.filter((check) => check.Status === 'pending').length, + taskDupe.statuses['pending'] ); }); }); @@ -105,32 +163,36 @@ module('Unit | Controller | allocations/allocation/index', function (hooks) { // Using var to hoist this variable to the top of the module var Allocation = { namespace: 'default', - name: 'trying-multi-dupes.fakepy[1]', - taskGroupName: 'fakepy', - resources: { - Cpu: null, - Memory: null, - MemoryMax: null, - Disk: null, - Iops: null, - Networks: [ + name: 'my-alloc', + taskGroup: { + name: 'fakepy', + count: 3, + services: [ { - Device: '', - CIDR: '', - IP: '127.0.0.1', - Mode: 'host', - MBits: 0, - Ports: [ - { - name: 'http', - port: 22308, - to: 0, - isDynamic: true, - }, - ], + Name: 'group-fake-py', + refID: 'fakepy-group-fake-py', + PortLabel: 'http', + Tags: [], + OnUpdate: 'require_healthy', + Provider: 'nomad', + Connect: null, + GroupName: 'fakepy', + TaskName: '', + healthChecks: [], + }, + { + Name: 'duper', + refID: 'fakepy-duper', + PortLabel: 'http', + Tags: [], + OnUpdate: 'require_healthy', + Provider: 'nomad', + Connect: null, + GroupName: 'fakepy', + TaskName: '', + healthChecks: [], }, ], - Ports: [], }, allocatedResources: { Cpu: 100, @@ -164,53 +226,48 @@ var Allocation = { }, ], }, - jobVersion: 0, - modifyIndex: 31, - modifyTime: '2022-08-29T14:13:57.761Z', - createIndex: 15, - createTime: '2022-08-29T14:08:57.587Z', - clientStatus: 'running', - desiredStatus: 'run', healthChecks: { - '93a090236c79d964d1381cb218efc0f5': { - Check: 'happy', + c97fda942e772b43a5a537e5b0c8544c: { + Check: 'service: "task-fake-py" check', Group: 'trying-multi-dupes.fakepy[1]', - ID: '93a090236c79d964d1381cb218efc0f5', + ID: 'c97fda942e772b43a5a537e5b0c8544c', Mode: 'healthiness', Output: 'nomad: http ok', - Service: 'fake-py', + Service: 'task-fake-py', Status: 'success', StatusCode: 200, - Timestamp: 1661787992, - }, - '4b5daa12d4159bcb367aac65548f48f4': { - Check: 'sad', - Group: 'trying-multi-dupes.fakepy[1]', - ID: '4b5daa12d4159bcb367aac65548f48f4', - Mode: 'healthiness', - Output: - '\n\n \n \n Error response\n \n \n

Error response

\n

Error code: 404

\n

Message: File not found.

\n

Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.

\n \n\n', - Service: 'fake-py', - Status: 'failure', - StatusCode: 404, - Timestamp: 1661787965, + Task: 'http.server', + Timestamp: 1662131947, }, - '73ad9b936fb3f3cc4d7f62a1aab6de53': { + '2e1bfc8ecc485ee86b972ae08e890152': { Check: 'task-happy', Group: 'trying-multi-dupes.fakepy[1]', - ID: '73ad9b936fb3f3cc4d7f62a1aab6de53', + ID: '2e1bfc8ecc485ee86b972ae08e890152', Mode: 'healthiness', Output: 'nomad: http ok', Service: 'task-fake-py', Status: 'success', StatusCode: 200, Task: 'http.server', - Timestamp: 1661787992, + Timestamp: 1662131949, }, - '19421ef816ae0d3eeeb81697bce0e261': { + '6162723ab20b268c25eda69b400dc9c6': { Check: 'task-sad', Group: 'trying-multi-dupes.fakepy[1]', - ID: '19421ef816ae0d3eeeb81697bce0e261', + ID: '6162723ab20b268c25eda69b400dc9c6', + Mode: 'healthiness', + Output: + '\n\n \n \n Error response\n \n \n

Error response

\n

Error code: 404

\n

Message: File not found.

\n

Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.

\n \n\n', + Service: 'task-fake-py', + Status: 'failure', + StatusCode: 404, + Task: 'http.server', + Timestamp: 1662131936, + }, + a4a7050175a2b236edcf613cb3563753: { + Check: 'task-sad2', + Group: 'trying-multi-dupes.fakepy[1]', + ID: 'a4a7050175a2b236edcf613cb3563753', Mode: 'healthiness', Output: '\n\n \n \n Error response\n \n \n

Error response

\n

Error code: 404

\n

Message: File not found.

\n

Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.

\n \n\n', @@ -218,140 +275,144 @@ var Allocation = { Status: 'failure', StatusCode: 404, Task: 'http.server', - Timestamp: 1661787965, + Timestamp: 1662131936, }, - '784d40e33fa4c960355bbda79fbd20f0': { + '2dfe58eb841bdfa704f0ae9ef5b5af5e': { Check: 'tcp_probe', Group: 'trying-multi-dupes.fakepy[1]', - ID: '784d40e33fa4c960355bbda79fbd20f0', + ID: '2dfe58eb841bdfa704f0ae9ef5b5af5e', Mode: 'readiness', Output: 'nomad: tcp ok', Service: 'web', Status: 'success', Task: 'http.server', - Timestamp: 1661787995, + Timestamp: 1662131949, + }, + '69021054964f4c461b3c4c4f456e16a8': { + Check: 'happy', + Group: 'trying-multi-dupes.fakepy[1]', + ID: '69021054964f4c461b3c4c4f456e16a8', + Mode: 'healthiness', + Output: 'nomad: http ok', + Service: 'group-fake-py', + Status: 'success', + StatusCode: 200, + Timestamp: 1662131949, + }, + '913f5b725ceecdd5ff48a9a51ddf8513': { + Check: 'sad', + Group: 'trying-multi-dupes.fakepy[1]', + ID: '913f5b725ceecdd5ff48a9a51ddf8513', + Mode: 'healthiness', + Output: + '\n\n \n \n Error response\n \n \n

Error response

\n

Error code: 404

\n

Message: File not found.

\n

Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.

\n \n\n', + Service: 'group-fake-py', + Status: 'failure', + StatusCode: 404, + Timestamp: 1662131936, + }, + bloop: { + Check: 'is-alive', + Group: 'trying-multi-dupes.fakepy[1]', + ID: 'bloop', + Mode: 'healthiness', + Service: 'pender', + Status: 'pending', + Task: 'http.server', + Timestamp: 1662131947, + }, + 'group-dupe': { + Check: 'is-alive', + Group: 'trying-multi-dupes.fakepy[1]', + ID: 'group-dupe', + Mode: 'healthiness', + Service: 'duper', + Status: 'success', + Task: '', + Timestamp: 1662131947, + }, + 'task-dupe': { + Check: 'is-alive', + Group: 'trying-multi-dupes.fakepy[1]', + ID: 'task-dupe', + Mode: 'healthiness', + Service: 'duper', + Status: 'failure', + Task: 'http.server', + Timestamp: 1662131947, }, }, - isMigrating: false, - wasPreempted: false, - allocationTaskGroup: { - Name: 'fakepy', - Count: 3, - Tasks: [ - { - Name: 'http.server', - Driver: 'raw_exec', - Kind: '', - Meta: null, - Lifecycle: null, - ReservedMemory: 300, - ReservedMemoryMax: 0, - ReservedCPU: 100, - ReservedDisk: 0, - ReservedEphemeralDisk: 300, - Services: [ + + states: [ + { + Name: 'http.server', + task: { + name: 'http.server', + driver: 'raw_exec', + kind: '', + meta: null, + lifecycle: null, + reservedMemory: 300, + reservedMemoryMax: 0, + reservedCPU: 100, + reservedDisk: 0, + reservedEphemeralDisk: 300, + services: [ { Name: 'task-fake-py', PortLabel: 'http', - Tags: [], + refID: 'http.server-task-fake-py', + Tags: [ + 'long', + 'and', + 'arbitrary', + 'list', + 'of', + 'tags', + 'arbitrary', + ], OnUpdate: 'require_healthy', Provider: 'nomad', Connect: null, + TaskName: 'http.server', + healthChecks: [], + }, + { + Name: 'pender', + refID: 'http.server-pender', + PortLabel: 'http', + Tags: ['lol', 'lmao'], + OnUpdate: 'require_healthy', + Provider: 'nomad', + Connect: null, + TaskName: 'http.server', + healthChecks: [], }, { Name: 'web', + refID: 'http.server-web', PortLabel: 'http', - Tags: ['web', 'tcp', 'lol', 'lmao'], + Tags: ['lol', 'lmao'], OnUpdate: 'require_healthy', Provider: 'nomad', Connect: null, + TaskName: 'http.server', + healthChecks: [], }, { Name: 'duper', + refID: 'http.server-duper', PortLabel: 'http', - Tags: ['web', 'tcp', 'lol', 'lmao'], + Tags: ['lol', 'lmao'], OnUpdate: 'require_healthy', Provider: 'nomad', Connect: null, + TaskName: 'http.server', + healthChecks: [], }, ], - VolumeMounts: null, - }, - ], - Services: [ - { - Name: 'fake-py', - PortLabel: 'http', - Tags: [], - OnUpdate: 'require_healthy', - Provider: 'nomad', - Connect: null, - }, - { - Name: 'duper', - PortLabel: 'http', - Tags: [], - OnUpdate: 'require_healthy', - Provider: 'nomad', - Connect: null, - }, - ], - Volumes: [], - Scaling: null, - Meta: null, - ReservedEphemeralDisk: 300, - }, - states: [ - { - Name: 'http.server', - State: 'running', - StartedAt: '2022-08-29T14:08:57.680Z', - FinishedAt: null, - Failed: false, - Resources: { - Cpu: 100, - Memory: 300, - MemoryMax: null, - Disk: null, - Iops: null, - Networks: [], - Ports: [], + volumeMounts: null, }, - Events: [ - { - Type: 'Received', - Signal: 0, - ExitCode: 0, - Time: '2022-08-29T14:08:57.592Z', - TimeNanos: 865024, - DisplayMessage: 'Task received by client', - }, - { - Type: 'Task Setup', - Signal: 0, - ExitCode: 0, - Time: '2022-08-29T14:08:57.595Z', - TimeNanos: 160064, - DisplayMessage: 'Building Task Directory', - }, - { - Type: 'Started', - Signal: 0, - ExitCode: 0, - Time: '2022-08-29T14:08:57.680Z', - TimeNanos: 728064, - DisplayMessage: 'Task started by client', - }, - { - Type: 'Alloc Unhealthy', - Signal: 0, - ExitCode: 0, - Time: '2022-08-29T14:13:57.592Z', - TimeNanos: 152064, - DisplayMessage: - 'Task not running for min_healthy_time of 10s by healthy_deadline of 5m0s', - }, - ], }, ], rescheduleEvents: [],