[ui] Service Discovery: Allocation Service fly-out (#14389)

* Bones of a new flyout section

* Basic sidebar behaviour and style edits

* Concept of a refID for service fragments to disambiguate task and group

* A11y audit etc

* Moves health check aggregation to serviceFragment model and retains history

* Has to be a getter

* flyout populated

* Sidebar styling

* Sidebar table and details added

* Mirage fixture

* Active status and table styles

* Unit test mock updated

* Acceptance tests for alloc services table and flyout

* Chart styles closer to mock

* Without a paused test

* Consul and Nomad icons in services table

* Alloc services test updates in light of new column changes

* without using an inherited scenario
This commit is contained in:
Phil Renaud
2022-09-07 10:23:39 -04:00
committed by Phil Renaud
parent 9f0d9c923b
commit e68f07e924
15 changed files with 804 additions and 344 deletions

View File

@@ -0,0 +1,130 @@
<div
class="sidebar has-subnav service-sidebar {{if this.isSideBarOpen "open"}}"
{{on-click-outside
@fns.closeSidebar
capture=true
}}
>
{{#if @service}}
{{keyboard-commands this.keyCommands}}
<header class="detail-header">
<h1 class="title">
{{@service.name}}
<span class="aggregate-status">
{{#if (eq this.aggregateStatus 'Unhealthy')}}
<FlightIcon @name="x-square-fill" @color="#c84034" />
Unhealthy
{{else}}
<FlightIcon @name="check-square-fill" @color="#25ba81" />
Healthy
{{/if}}
</span>
</h1>
<button
data-test-close-service-sidebar
class="button is-borderless"
type="button"
{{on "click" @fns.closeSidebar}}
>
{{x-icon "cancel"}}
</button>
</header>
<div class="boxed-section is-small">
<div
class="boxed-section-body inline-definitions"
>
<span class="label">
Service Details
</span>
<div>
<span class="pair">
<span class="term">
Allocation
</span>
<LinkTo
@route="allocations.allocation"
@model={{@allocation}}
@query={{hash service=""}}
>
{{@allocation.shortId}}
</LinkTo>
</span>
<span class="pair">
<span class="term">
IP Address &amp; Port
</span>
<a
href="http://{{this.address}}"
target="_blank"
rel="noopener noreferrer"
>
{{this.address}}
</a>
</span>
{{#if @service.tags.length}}
<span class="pair">
<span class="term">
Tags
</span>
{{join ", " @service.tags}}
</span>
{{/if}}
<span class="pair">
<span class="term">
Client
</span>
<LinkTo
@route="clients.client"
@model={{@allocation.node}}
>
{{@allocation.node.shortId}}
</LinkTo>
</span>
</div>
</div>
</div>
{{#if @service.mostRecentChecks.length}}
<ListTable class="health-checks" @source={{@service.mostRecentChecks}} as |t|>
<t.head>
<th>
Name
</th>
<th>
Status
</th>
<td>
Output
</td>
</t.head>
<t.body as |row|>
<tr data-service-health={{row.model.Status}}>
<td class="name">
<span title={{row.model.Check}}>{{row.model.Check}}</span>
</td>
<td class="status">
<span>
{{#if (eq row.model.Status "success")}}
<FlightIcon @name="check-square-fill" @color="#25ba81" />
Healthy
{{else if (eq row.model.Status "failure")}}
<FlightIcon @name="x-square-fill" @color="#c84034" />
Unhealthy
{{else if (eq row.model.Status "pending")}}
Pending
{{/if}}
</span>
</td>
<td class="service-output">
<code>
{{row.model.Output}}
</code>
</td>
</tr>
</t.body>
</ListTable>
{{/if}}
{{/if}}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -270,62 +270,49 @@
Services
</div>
<div class="boxed-section-body is-full-bleed">
<ListTable @source={{this.services}} as |t|>
<ListTable class="allocation-services-table" @source={{this.servicesWithHealthChecks}} as |t|>
<t.head>
<th class="is-2">
<th>
Name
</th>
<th class="is-1">
<th>
Port
</th>
<td>
Tags
</td>
<td>
On Update
</td>
<td>
Connect?
</td>
<td>
Upstreams
</td>
<td>
Health Check Status
</td>
</t.head>
<t.body as |row|>
<tr data-test-service>
<tr data-test-service class="is-interactive {{if (eq this.activeService row.model) "is-active"}}"
{{on "click" (fn this.handleServiceClick row.model)}}
{{keyboard-shortcut
enumerated=true
action=(fn this.handleServiceClick row.model)
}}
>
<td data-test-service-name>
{{#if (eq row.model.provider "nomad")}}
<FlightIcon @name="nomad-color" />
{{else}}
<FlightIcon @name="consul-color" />
{{/if}}
{{row.model.name}}
</td>
<td data-test-service-port>
{{row.model.portLabel}}
</td>
<td data-test-service-tags class="is-long-text">
{{join ", " row.model.tags}}
</td>
<td data-test-service-onupdate>
{{row.model.onUpdate}}
</td>
<td data-test-service-connect>
{{if row.model.connect "Yes" "No"}}
</td>
<td data-test-service-upstreams>
{{#each
row.model.connect.sidecarService.proxy.upstreams as |upstream|
}}
{{upstream.destinationName}}:{{upstream.localBindPort}}
{{#each row.model.tags as |tag|}}
<span class="tag">{{tag}}</span>
{{/each}}
</td>
<td data-test-service-health>
{{#if (eq row.model.provider "nomad")}}
<div class="inline-chart">
{{#if (is-empty row.model.taskName)}}
<ServiceStatusBar @isNarrow={{true}} @services={{this.serviceHealthStatuses}} @name={{concat row.model.groupName "-" row.model.name}} />
{{else}}
<ServiceStatusBar @isNarrow={{true}} @services={{this.serviceHealthStatuses}} @name={{concat row.model.taskName "-" row.model.name}} />
{{/if}}
<ServiceStatusBar @isNarrow={{true}} @status={{row.model.mostRecentCheckStatus}} />
</div>
{{/if}}
</td>
@@ -497,4 +484,11 @@
</div>
</div>
{{/if}}
<AllocationServiceSidebar
@service={{this.activeService}}
@allocation={{this.model}}
@fns={{hash
closeSidebar=this.closeSidebar
}}
/>
</section>

View File

@@ -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 :(',
'¯\\_(ツ)_/¯',
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"\n "http://www.w3.org/TR/html4/strict.dtd">\n<html>\n <head>\n <meta http-equiv="Content-Type" content="text/html;charset=utf-8">\n <title>Error response</title>\n </head>\n <body>\n <h1>Error response</h1>\n <p>Error code: 404</p>\n <p>Message: File not found.</p>\n <p>Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.</p>\n </body>\n</html>\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
}

View File

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

View File

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

View File

@@ -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`<AllocationServiceSidebar @service={{this.service}} @fns={{hash closeSidebar=this.closeSidebar}} />`
);
assert.dom('h1').includesText('Funky Service');
assert.dom('.sidebar').hasClass('open');
this.set('service', null);
await render(
hbs`<AllocationServiceSidebar @service={{this.service}} @fns={{hash closeSidebar=this.closeSidebar}} />`
);
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');
});
}
);

View File

@@ -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 = {
pending: 1,
failure: 1,
};
const pendingService = {
pending: 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`
<div class="inline-chart">
<ServiceStatusBar
@services={{this.services}}
@status={{this.serviceStatus}}
@name="peter"
/>
</div>

View File

@@ -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']
);
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', taskFakePy.refID)
.healthChecks.filter((check) => check.Status === 'success').length,
taskFakePy.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', taskFakePy.refID)
.healthChecks.filter((check) => check.Status === 'failure').length,
taskFakePy.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', taskFakePy.refID)
.healthChecks.filter((check) => check.Status === 'pending').length,
taskFakePy.statuses['pending']
);
assert.equal(
controller.servicesWithHealthChecks
.findBy('refID', pender.refID)
.healthChecks.filter((check) => check.Status === 'success').length,
pender.statuses['success']
);
assert.equal(
controller.servicesWithHealthChecks
.findBy('refID', pender.refID)
.healthChecks.filter((check) => check.Status === 'failure').length,
pender.statuses['failure']
);
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,35 @@ 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,
Task: 'http.server',
Timestamp: 1662131947,
},
'4b5daa12d4159bcb367aac65548f48f4': {
Check: 'sad',
Group: 'trying-multi-dupes.fakepy[1]',
ID: '4b5daa12d4159bcb367aac65548f48f4',
Mode: 'healthiness',
Output:
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"\n "http://www.w3.org/TR/html4/strict.dtd">\n<html>\n <head>\n <meta http-equiv="Content-Type" content="text/html;charset=utf-8">\n <title>Error response</title>\n </head>\n <body>\n <h1>Error response</h1>\n <p>Error code: 404</p>\n <p>Message: File not found.</p>\n <p>Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.</p>\n </body>\n</html>\n',
Service: 'fake-py',
Status: 'failure',
StatusCode: 404,
Timestamp: 1661787965,
},
'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:
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"\n "http://www.w3.org/TR/html4/strict.dtd">\n<html>\n <head>\n <meta http-equiv="Content-Type" content="text/html;charset=utf-8">\n <title>Error response</title>\n </head>\n <body>\n <h1>Error response</h1>\n <p>Error code: 404</p>\n <p>Message: File not found.</p>\n <p>Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.</p>\n </body>\n</html>\n',
@@ -218,140 +262,157 @@ var Allocation = {
Status: 'failure',
StatusCode: 404,
Task: 'http.server',
Timestamp: 1661787965,
Timestamp: 1662131936,
},
'784d40e33fa4c960355bbda79fbd20f0': {
a4a7050175a2b236edcf613cb3563753: {
Check: 'task-sad2',
Group: 'trying-multi-dupes.fakepy[1]',
ID: 'a4a7050175a2b236edcf613cb3563753',
Mode: 'healthiness',
Output:
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"\n "http://www.w3.org/TR/html4/strict.dtd">\n<html>\n <head>\n <meta http-equiv="Content-Type" content="text/html;charset=utf-8">\n <title>Error response</title>\n </head>\n <body>\n <h1>Error response</h1>\n <p>Error code: 404</p>\n <p>Message: File not found.</p>\n <p>Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.</p>\n </body>\n</html>\n',
Service: 'task-fake-py',
Status: 'failure',
StatusCode: 404,
Task: 'http.server',
Timestamp: 1662131936,
},
'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:
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"\n "http://www.w3.org/TR/html4/strict.dtd">\n<html>\n <head>\n <meta http-equiv="Content-Type" content="text/html;charset=utf-8">\n <title>Error response</title>\n </head>\n <body>\n <h1>Error response</h1>\n <p>Error code: 404</p>\n <p>Message: File not found.</p>\n <p>Error code explanation: HTTPStatus.NOT_FOUND - Nothing matches the given URI.</p>\n </body>\n</html>\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: [
{
Name: 'task-fake-py',
PortLabel: 'http',
Tags: [],
OnUpdate: 'require_healthy',
Provider: 'nomad',
Connect: null,
},
{
Name: 'web',
PortLabel: 'http',
Tags: ['web', 'tcp', 'lol', 'lmao'],
OnUpdate: 'require_healthy',
Provider: 'nomad',
Connect: null,
},
{
Name: 'duper',
PortLabel: 'http',
Tags: ['web', 'tcp', 'lol', 'lmao'],
OnUpdate: 'require_healthy',
Provider: 'nomad',
Connect: null,
},
],
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: [],
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',
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: ['lol', 'lmao'],
OnUpdate: 'require_healthy',
Provider: 'nomad',
Connect: null,
TaskName: 'http.server',
healthChecks: [],
},
{
Name: 'duper',
refID: 'http.server-duper',
PortLabel: 'http',
Tags: ['lol', 'lmao'],
OnUpdate: 'require_healthy',
Provider: 'nomad',
Connect: null,
TaskName: 'http.server',
healthChecks: [],
},
],
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: [],