Merge pull request #14408 from hashicorp/f-ui/service-discovery
Service discovery in the Nomad UI
This commit is contained in:
commit
59cf87ca6f
3
.changelog/14408.txt
Normal file
3
.changelog/14408.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
```release-note:improvement
|
||||||
|
ui: add service discovery, along with health checks, to job and allocation routes
|
||||||
|
```
|
|
@ -35,6 +35,14 @@ export default class AllocationAdapter extends Watchable {
|
||||||
)
|
)
|
||||||
.then(handleFSResponse);
|
.then(handleFSResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async check(model) {
|
||||||
|
const res = await this.token.authorizedRequest(
|
||||||
|
`/v1/client/allocation/${model.id}/checks`
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFSResponse(response) {
|
async function handleFSResponse(response) {
|
||||||
|
|
5
ui/app/adapters/service.js
Normal file
5
ui/app/adapters/service.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import Watchable from './watchable';
|
||||||
|
import classic from 'ember-classic-decorator';
|
||||||
|
|
||||||
|
@classic
|
||||||
|
export default class ServiceAdapter extends Watchable {}
|
|
@ -143,9 +143,9 @@ export default class Watchable extends ApplicationAdapter {
|
||||||
reloadRelationship(
|
reloadRelationship(
|
||||||
model,
|
model,
|
||||||
relationshipName,
|
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);
|
const relationship = model.relationshipFor(relationshipName);
|
||||||
if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') {
|
if (relationship.kind !== 'belongsTo' && relationship.kind !== 'hasMany') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
@ -185,6 +185,9 @@ export default class Watchable extends ApplicationAdapter {
|
||||||
modelClass,
|
modelClass,
|
||||||
json
|
json
|
||||||
);
|
);
|
||||||
|
if (replace) {
|
||||||
|
store.unloadAll(relationship.type);
|
||||||
|
}
|
||||||
store.push(normalizedData);
|
store.push(normalizedData);
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|
130
ui/app/components/allocation-service-sidebar.hbs
Normal file
130
ui/app/components/allocation-service-sidebar.hbs
Normal 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 & 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>
|
41
ui/app/components/allocation-service-sidebar.js
Normal file
41
ui/app/components/allocation-service-sidebar.js
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
17
ui/app/components/job-service-row.js
Normal file
17
ui/app/components/job-service-row.js
Normal file
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,19 +8,19 @@ import classic from 'ember-classic-decorator';
|
||||||
export default class ServiceStatusBar extends DistributionBar {
|
export default class ServiceStatusBar extends DistributionBar {
|
||||||
layoutName = 'components/distribution-bar';
|
layoutName = 'components/distribution-bar';
|
||||||
|
|
||||||
services = null;
|
status = null;
|
||||||
|
|
||||||
'data-test-service-status-bar' = true;
|
'data-test-service-status-bar' = true;
|
||||||
|
|
||||||
@computed('services.@each.status')
|
@computed('status.{failure,pending,success}')
|
||||||
get data() {
|
get data() {
|
||||||
if (!this.services) {
|
if (!this.status) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = this.services.filterBy('status', 'pending').length;
|
const pending = this.status.pending || 0;
|
||||||
const failing = this.services.filterBy('status', 'failing').length;
|
const failing = this.status.failure || 0;
|
||||||
const success = this.services.filterBy('status', 'success').length;
|
const success = this.status.success || 0;
|
||||||
|
|
||||||
const [grey, red, green] = ['queued', 'failed', 'complete'];
|
const [grey, red, green] = ['queued', 'failed', 'complete'];
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { watchRecord } from 'nomad-ui/utils/properties/watch';
|
||||||
import messageForError from 'nomad-ui/utils/message-from-adapter-error';
|
import messageForError from 'nomad-ui/utils/message-from-adapter-error';
|
||||||
import classic from 'ember-classic-decorator';
|
import classic from 'ember-classic-decorator';
|
||||||
import { union } from '@ember/object/computed';
|
import { union } from '@ember/object/computed';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
@classic
|
@classic
|
||||||
export default class IndexController extends Controller.extend(Sortable) {
|
export default class IndexController extends Controller.extend(Sortable) {
|
||||||
|
@ -25,6 +26,9 @@ export default class IndexController extends Controller.extend(Sortable) {
|
||||||
{
|
{
|
||||||
sortDescending: 'desc',
|
sortDescending: 'desc',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
activeServiceID: 'service',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
sortProperty = 'name';
|
sortProperty = 'name';
|
||||||
|
@ -55,7 +59,7 @@ export default class IndexController extends Controller.extend(Sortable) {
|
||||||
@computed('tasks.@each.services')
|
@computed('tasks.@each.services')
|
||||||
get taskServices() {
|
get taskServices() {
|
||||||
return this.get('tasks')
|
return this.get('tasks')
|
||||||
.map((t) => ((t && t.get('services')) || []).toArray())
|
.map((t) => ((t && t.services) || []).toArray())
|
||||||
.flat()
|
.flat()
|
||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
@ -67,6 +71,35 @@ export default class IndexController extends Controller.extend(Sortable) {
|
||||||
|
|
||||||
@union('taskServices', 'groupServices') services;
|
@union('taskServices', 'groupServices') services;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onDismiss() {
|
onDismiss() {
|
||||||
this.set('error', null);
|
this.set('error', null);
|
||||||
}
|
}
|
||||||
|
@ -131,4 +164,31 @@ export default class IndexController extends Controller.extend(Sortable) {
|
||||||
taskClick(allocation, task, event) {
|
taskClick(allocation, task, event) {
|
||||||
lazyClick([() => this.send('gotoTask', 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
|
||||||
}
|
}
|
||||||
|
|
3
ui/app/controllers/jobs/job/services.js
Normal file
3
ui/app/controllers/jobs/job/services.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import Controller from '@ember/controller';
|
||||||
|
|
||||||
|
export default class JobsJobServicesController extends Controller {}
|
75
ui/app/controllers/jobs/job/services/index.js
Normal file
75
ui/app/controllers/jobs/job/services/index.js
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import Controller from '@ember/controller';
|
||||||
|
import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting';
|
||||||
|
import Sortable from 'nomad-ui/mixins/sortable';
|
||||||
|
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,
|
||||||
|
Sortable
|
||||||
|
) {
|
||||||
|
@alias('model') job;
|
||||||
|
@alias('job.taskGroups') taskGroups;
|
||||||
|
|
||||||
|
queryParams = [
|
||||||
|
{
|
||||||
|
sortProperty: 'sort',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sortDescending: 'desc',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
sortProperty = 'name';
|
||||||
|
sortDescending = false;
|
||||||
|
|
||||||
|
@alias('services') listToSort;
|
||||||
|
@alias('listSorted') sortedServices;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
13
ui/app/controllers/jobs/job/services/service.js
Normal file
13
ui/app/controllers/jobs/job/services/service.js
Normal file
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import { equal, none } from '@ember/object/computed';
|
||||||
import Model from '@ember-data/model';
|
import Model from '@ember-data/model';
|
||||||
import { attr, belongsTo, hasMany } from '@ember-data/model';
|
import { attr, belongsTo, hasMany } from '@ember-data/model';
|
||||||
import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
|
import { fragment, fragmentArray } from 'ember-data-model-fragments/attributes';
|
||||||
|
import isEqual from 'lodash.isequal';
|
||||||
import intersection from 'lodash.intersection';
|
import intersection from 'lodash.intersection';
|
||||||
import shortUUIDProperty from '../utils/properties/short-uuid';
|
import shortUUIDProperty from '../utils/properties/short-uuid';
|
||||||
import classic from 'ember-classic-decorator';
|
import classic from 'ember-classic-decorator';
|
||||||
|
@ -20,6 +21,7 @@ const STATUS_ORDER = {
|
||||||
@classic
|
@classic
|
||||||
export default class Allocation extends Model {
|
export default class Allocation extends Model {
|
||||||
@service token;
|
@service token;
|
||||||
|
@service store;
|
||||||
|
|
||||||
@shortUUIDProperty('id') shortId;
|
@shortUUIDProperty('id') shortId;
|
||||||
@belongsTo('job') job;
|
@belongsTo('job') job;
|
||||||
|
@ -40,6 +42,17 @@ export default class Allocation extends Model {
|
||||||
@attr('string') clientStatus;
|
@attr('string') clientStatus;
|
||||||
@attr('string') desiredStatus;
|
@attr('string') desiredStatus;
|
||||||
|
|
||||||
|
@attr healthChecks;
|
||||||
|
|
||||||
|
async getServiceHealth() {
|
||||||
|
const data = await this.store.adapterFor('allocation').check(this);
|
||||||
|
|
||||||
|
// Compare Results
|
||||||
|
if (!isEqual(this.healthChecks, data)) {
|
||||||
|
this.set('healthChecks', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@computed('')
|
@computed('')
|
||||||
get plainJobId() {
|
get plainJobId() {
|
||||||
return JSON.parse(this.belongsTo('job').id())[0];
|
return JSON.parse(this.belongsTo('job').id())[0];
|
||||||
|
|
|
@ -141,6 +141,7 @@ export default class Job extends Model {
|
||||||
@hasMany('variables') variables;
|
@hasMany('variables') variables;
|
||||||
@belongsTo('namespace') namespace;
|
@belongsTo('namespace') namespace;
|
||||||
@belongsTo('job-scale') scaleState;
|
@belongsTo('job-scale') scaleState;
|
||||||
|
@hasMany('services') services;
|
||||||
|
|
||||||
@hasMany('recommendation-summary') recommendationSummaries;
|
@hasMany('recommendation-summary') recommendationSummaries;
|
||||||
|
|
||||||
|
|
47
ui/app/models/service-fragment.js
Normal file
47
ui/app/models/service-fragment.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
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;
|
||||||
|
@attr() tags;
|
||||||
|
@attr('string') onUpdate;
|
||||||
|
@attr('string') provider;
|
||||||
|
@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;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,33 @@
|
||||||
import { attr } from '@ember-data/model';
|
// @ts-check
|
||||||
import Fragment from 'ember-data-model-fragments/fragment';
|
import { attr, belongsTo } from '@ember-data/model';
|
||||||
import { fragment } from 'ember-data-model-fragments/attributes';
|
import Model from '@ember-data/model';
|
||||||
|
import { alias } from '@ember/object/computed';
|
||||||
|
|
||||||
export default class Service extends Fragment {
|
export default class Service extends Model {
|
||||||
@attr('string') name;
|
@belongsTo('allocation') allocation;
|
||||||
@attr('string') portLabel;
|
@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() 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ export default class TaskGroup extends Fragment {
|
||||||
|
|
||||||
@fragmentArray('task') tasks;
|
@fragmentArray('task') tasks;
|
||||||
|
|
||||||
@fragmentArray('service') services;
|
@fragmentArray('service-fragment') services;
|
||||||
|
|
||||||
@fragmentArray('volume-definition') volumes;
|
@fragmentArray('volume-definition') volumes;
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,7 @@ export default class Task extends Fragment {
|
||||||
@attr('number') reservedCPU;
|
@attr('number') reservedCPU;
|
||||||
@attr('number') reservedDisk;
|
@attr('number') reservedDisk;
|
||||||
@attr('number') reservedEphemeralDisk;
|
@attr('number') reservedEphemeralDisk;
|
||||||
@fragmentArray('service') services;
|
@fragmentArray('service-fragment') services;
|
||||||
|
|
||||||
@fragmentArray('volume-mount', { defaultValue: () => [] }) volumeMounts;
|
@fragmentArray('volume-mount', { defaultValue: () => [] }) volumeMounts;
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,9 @@ Router.map(function () {
|
||||||
this.route('evaluations');
|
this.route('evaluations');
|
||||||
this.route('allocations');
|
this.route('allocations');
|
||||||
this.route('clients');
|
this.route('clients');
|
||||||
|
this.route('services', function () {
|
||||||
|
this.route('service', { path: '/:name' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import Route from '@ember/routing/route';
|
import Route from '@ember/routing/route';
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
import { collect } from '@ember/object/computed';
|
import { collect } from '@ember/object/computed';
|
||||||
import { watchRecord } from 'nomad-ui/utils/properties/watch';
|
import {
|
||||||
|
watchRecord,
|
||||||
|
watchNonStoreRecords,
|
||||||
|
} from 'nomad-ui/utils/properties/watch';
|
||||||
import WithWatchers from 'nomad-ui/mixins/with-watchers';
|
import WithWatchers from 'nomad-ui/mixins/with-watchers';
|
||||||
import notifyError from 'nomad-ui/utils/notify-error';
|
import notifyError from 'nomad-ui/utils/notify-error';
|
||||||
export default class AllocationRoute extends Route.extend(WithWatchers) {
|
export default class AllocationRoute extends Route.extend(WithWatchers) {
|
||||||
|
@ -10,6 +13,21 @@ export default class AllocationRoute extends Route.extend(WithWatchers) {
|
||||||
startWatchers(controller, model) {
|
startWatchers(controller, model) {
|
||||||
if (model) {
|
if (model) {
|
||||||
controller.set('watcher', this.watch.perform(model));
|
controller.set('watcher', this.watch.perform(model));
|
||||||
|
|
||||||
|
// Conditionally Long Poll /checks endpoint if alloc has nomad services
|
||||||
|
const doesAllocHaveServices =
|
||||||
|
!!model.taskGroup?.services?.filterBy('provider', 'nomad').length ||
|
||||||
|
!!model.states
|
||||||
|
?.mapBy('task')
|
||||||
|
?.map((t) => t && t.get('services'))[0]
|
||||||
|
?.filterBy('provider', 'nomad').length;
|
||||||
|
|
||||||
|
if (doesAllocHaveServices) {
|
||||||
|
controller.set(
|
||||||
|
'watchHealthChecks',
|
||||||
|
this.watchHealthChecks.perform(model, 'getServiceHealth')
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,6 +46,7 @@ export default class AllocationRoute extends Route.extend(WithWatchers) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@watchRecord('allocation') watch;
|
@watchRecord('allocation') watch;
|
||||||
|
@watchNonStoreRecords('allocation') watchHealthChecks;
|
||||||
|
|
||||||
@collect('watch') watchers;
|
@collect('watch', 'watchHealthChecks') watchers;
|
||||||
}
|
}
|
||||||
|
|
26
ui/app/routes/jobs/job/services.js
Normal file
26
ui/app/routes/jobs/job/services.js
Normal file
|
@ -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;
|
||||||
|
}
|
3
ui/app/routes/jobs/job/services/index.js
Normal file
3
ui/app/routes/jobs/job/services/index.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
|
||||||
|
export default class JobsJobServicesIndexRoute extends Route {}
|
12
ui/app/routes/jobs/job/services/service.js
Normal file
12
ui/app/routes/jobs/job/services/service.js
Normal file
|
@ -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 || [] };
|
||||||
|
}
|
||||||
|
}
|
|
@ -101,6 +101,11 @@ export default class JobSerializer extends ApplicationSerializer {
|
||||||
related: buildURL(`${jobURL}/evaluations`, { namespace }),
|
related: buildURL(`${jobURL}/evaluations`, { namespace }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
services: {
|
||||||
|
links: {
|
||||||
|
related: buildURL(`${jobURL}/services`, { namespace }),
|
||||||
|
},
|
||||||
|
},
|
||||||
variables: {
|
variables: {
|
||||||
links: {
|
links: {
|
||||||
related: buildURL(`/${apiNamespace}/vars`, {
|
related: buildURL(`/${apiNamespace}/vars`, {
|
||||||
|
|
11
ui/app/serializers/service-fragment.js
Normal file
11
ui/app/serializers/service-fragment.js
Normal file
|
@ -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'];
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
import ApplicationSerializer from './application';
|
|
||||||
import classic from 'ember-classic-decorator';
|
import classic from 'ember-classic-decorator';
|
||||||
|
import ApplicationSerializer from './application';
|
||||||
|
|
||||||
@classic
|
@classic
|
||||||
export default class ServiceSerializer extends ApplicationSerializer {
|
export default class ServiceSerializer extends ApplicationSerializer {
|
||||||
attrs = {
|
normalize(typeHash, hash) {
|
||||||
connect: 'Connect',
|
hash.AllocationID = hash.AllocID;
|
||||||
};
|
hash.JobID = JSON.stringify([hash.JobID, hash.Namespace]);
|
||||||
|
return super.normalize(typeHash, hash);
|
||||||
arrayNullOverrides = ['Tags'];
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,11 @@ export default class TaskGroup extends ApplicationSerializer {
|
||||||
mapToArray = ['Volumes'];
|
mapToArray = ['Volumes'];
|
||||||
|
|
||||||
normalize(typeHash, hash) {
|
normalize(typeHash, hash) {
|
||||||
|
if (hash.Services) {
|
||||||
|
hash.Services.forEach((service) => {
|
||||||
|
service.GroupName = hash.Name;
|
||||||
|
});
|
||||||
|
}
|
||||||
// Provide EphemeralDisk to each task
|
// Provide EphemeralDisk to each task
|
||||||
hash.Tasks.forEach((task) => {
|
hash.Tasks.forEach((task) => {
|
||||||
task.EphemeralDisk = copy(hash.EphemeralDisk);
|
task.EphemeralDisk = copy(hash.EphemeralDisk);
|
||||||
|
|
|
@ -48,3 +48,4 @@
|
||||||
@import './components/evaluations';
|
@import './components/evaluations';
|
||||||
@import './components/variables';
|
@import './components/variables';
|
||||||
@import './components/keyboard-shortcuts-modal';
|
@import './components/keyboard-shortcuts-modal';
|
||||||
|
@import './components/services';
|
||||||
|
|
69
ui/app/styles/components/services.scss
Normal file
69
ui/app/styles/components/services.scss
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
.service-list {
|
||||||
|
.title {
|
||||||
|
.back-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #363636;
|
||||||
|
position: relative;
|
||||||
|
top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td svg {
|
||||||
|
position: relative;
|
||||||
|
top: 3px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,6 @@
|
||||||
|
$topNavOffset: 112px;
|
||||||
|
$subNavOffset: 49px;
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
@ -6,14 +9,17 @@
|
||||||
right: 0%;
|
right: 0%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
top: 112px;
|
top: $topNavOffset;
|
||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
transition-duration: 150ms;
|
transition-duration: 150ms;
|
||||||
transition-timing-function: ease;
|
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;
|
z-index: $z-modal;
|
||||||
&.open {
|
&.open {
|
||||||
transform: translateX(0%);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -270,51 +270,52 @@
|
||||||
Services
|
Services
|
||||||
</div>
|
</div>
|
||||||
<div class="boxed-section-body is-full-bleed">
|
<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>
|
<t.head>
|
||||||
<th class="is-2">
|
<th>
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th class="is-1">
|
<th>
|
||||||
Port
|
Port
|
||||||
</th>
|
</th>
|
||||||
<td>
|
<td>
|
||||||
Tags
|
Tags
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
On Update
|
Health Check Status
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
Connect?
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
Upstreams
|
|
||||||
</td>
|
</td>
|
||||||
</t.head>
|
</t.head>
|
||||||
<t.body as |row|>
|
<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>
|
<td data-test-service-name>
|
||||||
|
{{#if (eq row.model.provider "nomad")}}
|
||||||
|
<FlightIcon @name="nomad-color" />
|
||||||
|
{{else}}
|
||||||
|
<FlightIcon @name="consul-color" />
|
||||||
|
{{/if}}
|
||||||
{{row.model.name}}
|
{{row.model.name}}
|
||||||
</td>
|
</td>
|
||||||
<td data-test-service-port>
|
<td data-test-service-port>
|
||||||
{{row.model.portLabel}}
|
{{row.model.portLabel}}
|
||||||
</td>
|
</td>
|
||||||
<td data-test-service-tags class="is-long-text">
|
<td data-test-service-tags class="is-long-text">
|
||||||
{{join ", " row.model.tags}}
|
{{#each row.model.tags as |tag|}}
|
||||||
</td>
|
<span class="tag">{{tag}}</span>
|
||||||
<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}}
|
{{/each}}
|
||||||
</td>
|
</td>
|
||||||
|
<td data-test-service-health>
|
||||||
|
{{#if (eq row.model.provider "nomad")}}
|
||||||
|
<div class="inline-chart">
|
||||||
|
<ServiceStatusBar @isNarrow={{true}} @status={{row.model.mostRecentCheckStatus}} />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</t.body>
|
</t.body>
|
||||||
</ListTable>
|
</ListTable>
|
||||||
|
@ -483,4 +484,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
<AllocationServiceSidebar
|
||||||
|
@service={{this.activeService}}
|
||||||
|
@allocation={{this.model}}
|
||||||
|
@fns={{hash
|
||||||
|
closeSidebar=this.closeSidebar
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
42
ui/app/templates/components/job-service-row.hbs
Normal file
42
ui/app/templates/components/job-service-row.hbs
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<tr
|
||||||
|
data-test-service-row
|
||||||
|
data-test-service-name={{@service.name}}
|
||||||
|
data-test-num-allocs={{@service.instances.length}}
|
||||||
|
data-test-service-provider={{@service.provider}}
|
||||||
|
data-test-service-level={{@service.level}}
|
||||||
|
{{on "click" (fn this.gotoService @service)}}
|
||||||
|
class={{if (eq @service.provider "nomad") "is-interactive"}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
{{keyboard-shortcut
|
||||||
|
enumerated=true
|
||||||
|
action=(action "gotoService" @service)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{{#if (eq @service.provider "nomad")}}
|
||||||
|
<FlightIcon @name="nomad-color" />
|
||||||
|
<LinkTo class="is-primary" @route="jobs.job.services.service" @model={{@service}} @query={{hash level=@service.level}}>{{@service.name}}</LinkTo>
|
||||||
|
{{else}}
|
||||||
|
<FlightIcon @name="consul-color" />
|
||||||
|
{{@service.name}}
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{@service.level}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<LinkTo @route="clients.client" @model={{@service.instances.0.node.id}}>{{@service.instances.0.node.shortId}}</LinkTo>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#each @service.tags as |tag|}}
|
||||||
|
<span class="tag">{{tag}}</span>
|
||||||
|
{{/each}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#if (eq @service.provider "nomad")}}
|
||||||
|
{{@service.instances.length}} {{pluralize "allocation" @service.instances.length}}
|
||||||
|
{{else}}
|
||||||
|
--
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
|
@ -68,5 +68,14 @@
|
||||||
</LinkTo>
|
</LinkTo>
|
||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
<li data-test-tab="services">
|
||||||
|
<LinkTo
|
||||||
|
@route="jobs.job.services"
|
||||||
|
@model={{@job}}
|
||||||
|
@activeClass="is-active"
|
||||||
|
>
|
||||||
|
Services
|
||||||
|
</LinkTo>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
3
ui/app/templates/jobs/job/services.hbs
Normal file
3
ui/app/templates/jobs/job/services.hbs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{{page-title "Job " @model.name " services"}}
|
||||||
|
<JobSubnav @job={{@model}} />
|
||||||
|
{{outlet}}
|
38
ui/app/templates/jobs/job/services/index.hbs
Normal file
38
ui/app/templates/jobs/job/services/index.hbs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<section class="section service-list">
|
||||||
|
{{#if this.sortedServices.length}}
|
||||||
|
<ListTable
|
||||||
|
@source={{this.sortedServices}}
|
||||||
|
@sortProperty={{this.sortProperty}}
|
||||||
|
@sortDescending={{this.sortDescending}}
|
||||||
|
as |t|
|
||||||
|
>
|
||||||
|
<t.head>
|
||||||
|
<t.sort-by @prop="name">Name</t.sort-by>
|
||||||
|
<t.sort-by @prop="level">Level</t.sort-by>
|
||||||
|
<t.sort-by @prop="client">Client</t.sort-by>
|
||||||
|
<th>Tags</th>
|
||||||
|
<t.sort-by @prop="numAllocs">Number of Alocations</t.sort-by>
|
||||||
|
</t.head>
|
||||||
|
<t.body as |row|>
|
||||||
|
<JobServiceRow
|
||||||
|
{{keyboard-shortcut
|
||||||
|
enumerated=true
|
||||||
|
action=(action "gotoService" row.model)
|
||||||
|
}}
|
||||||
|
@service={{row.model}}
|
||||||
|
/>
|
||||||
|
</t.body>
|
||||||
|
</ListTable>
|
||||||
|
{{else}}
|
||||||
|
<div class="boxed-section-body">
|
||||||
|
<div class="empty-message" data-test-empty-services-list>
|
||||||
|
<h3 class="empty-message-headline" data-test-empty-services-list-headline>
|
||||||
|
No Services
|
||||||
|
</h3>
|
||||||
|
<p class="empty-message-body">
|
||||||
|
No services running on {{this.job.name}}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</section>
|
37
ui/app/templates/jobs/job/services/service.hbs
Normal file
37
ui/app/templates/jobs/job/services/service.hbs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<section class="section service-list">
|
||||||
|
<h1 class="title">
|
||||||
|
<LinkTo class="back-link" @route="jobs.job.services">
|
||||||
|
<FlightIcon
|
||||||
|
@name="chevron-left"
|
||||||
|
@title="Back to services"
|
||||||
|
@size="24"
|
||||||
|
/>
|
||||||
|
</LinkTo>
|
||||||
|
{{this.model.name}}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<ListTable
|
||||||
|
@source={{this.model.instances}}
|
||||||
|
as |t|
|
||||||
|
>
|
||||||
|
<t.head>
|
||||||
|
<th>Allocation</th>
|
||||||
|
<th>IP Address & Port</th>
|
||||||
|
</t.head>
|
||||||
|
<t.body as |row|>
|
||||||
|
<tr data-test-service-row>
|
||||||
|
<td
|
||||||
|
{{keyboard-shortcut
|
||||||
|
enumerated=true
|
||||||
|
action=(action "gotoAllocation" row.model.allocation)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LinkTo @route="allocations.allocation" @model={{row.model.allocation}}>{{row.model.allocation.shortId}}</LinkTo>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{row.model.address}}:{{row.model.port}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</t.body>
|
||||||
|
</ListTable>
|
||||||
|
</section>
|
|
@ -39,7 +39,7 @@ export function watchRecord(modelName) {
|
||||||
}).drop();
|
}).drop();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function watchRelationship(relationshipName) {
|
export function watchRelationship(relationshipName, replace = false) {
|
||||||
return task(function* (model, throttle = 2000) {
|
return task(function* (model, throttle = 2000) {
|
||||||
assert(
|
assert(
|
||||||
'To watch a relationship, the adapter of the model provided to the watchRelationship task MUST extend Watchable',
|
'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, {
|
.reloadRelationship(model, relationshipName, {
|
||||||
watch: true,
|
watch: true,
|
||||||
abortController: controller,
|
abortController: controller,
|
||||||
|
replace,
|
||||||
}),
|
}),
|
||||||
wait(throttle),
|
wait(throttle),
|
||||||
]);
|
]);
|
||||||
|
@ -67,6 +68,27 @@ export function watchRelationship(relationshipName) {
|
||||||
}).drop();
|
}).drop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function watchNonStoreRecords(modelName) {
|
||||||
|
return task(function* (model, asyncCallbackName, throttle = 5000) {
|
||||||
|
assert(
|
||||||
|
'To watch a non-store records, the adapter of the model provided to the watchNonStoreRecords task MUST extend Watchable',
|
||||||
|
this.store.adapterFor(modelName) instanceof Watchable
|
||||||
|
);
|
||||||
|
while (isEnabled && !Ember.testing) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
try {
|
||||||
|
yield model[asyncCallbackName]();
|
||||||
|
yield wait(throttle);
|
||||||
|
} catch (e) {
|
||||||
|
yield e;
|
||||||
|
break;
|
||||||
|
} finally {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).drop();
|
||||||
|
}
|
||||||
|
|
||||||
export function watchAll(modelName) {
|
export function watchAll(modelName) {
|
||||||
return task(function* (throttle = 2000) {
|
return task(function* (throttle = 2000) {
|
||||||
assert(
|
assert(
|
||||||
|
|
|
@ -884,6 +884,47 @@ export default function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
//#endregion Variables
|
//#endregion Variables
|
||||||
|
|
||||||
|
//#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
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterKeys(object, ...keys) {
|
function filterKeys(object, ...keys) {
|
||||||
|
|
|
@ -185,6 +185,9 @@ export default Factory.extend({
|
||||||
// When true, task groups will have services
|
// When true, task groups will have services
|
||||||
withGroupServices: false,
|
withGroupServices: false,
|
||||||
|
|
||||||
|
// When true, tasks will have services
|
||||||
|
withTaskServices: false,
|
||||||
|
|
||||||
// When true, dynamic application sizing recommendations will be made
|
// When true, dynamic application sizing recommendations will be made
|
||||||
createRecommendations: false,
|
createRecommendations: false,
|
||||||
|
|
||||||
|
@ -211,6 +214,7 @@ export default Factory.extend({
|
||||||
createAllocations: job.createAllocations,
|
createAllocations: job.createAllocations,
|
||||||
withRescheduling: job.withRescheduling,
|
withRescheduling: job.withRescheduling,
|
||||||
withServices: job.withGroupServices,
|
withServices: job.withGroupServices,
|
||||||
|
withTaskServices: job.withTaskServices,
|
||||||
createRecommendations: job.createRecommendations,
|
createRecommendations: job.createRecommendations,
|
||||||
shallow: job.shallow,
|
shallow: job.shallow,
|
||||||
};
|
};
|
||||||
|
|
36
ui/mirage/factories/service-fragment.js
Normal file
36
ui/mirage/factories/service-fragment.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { Factory } from 'ember-cli-mirage';
|
||||||
|
import faker from 'nomad-ui/mirage/faker';
|
||||||
|
import { provide } from '../utils';
|
||||||
|
import { dasherize } from '@ember/string';
|
||||||
|
import { pickOne } from '../utils';
|
||||||
|
|
||||||
|
const ON_UPDATE = ['default', 'ignore', 'ignore_warnings'];
|
||||||
|
|
||||||
|
export default Factory.extend({
|
||||||
|
name: (id) => `${dasherize(faker.hacker.noun())}-${id}-service`,
|
||||||
|
portLabel: () => dasherize(faker.hacker.noun()),
|
||||||
|
onUpdate: faker.helpers.randomize(ON_UPDATE),
|
||||||
|
provider: () => pickOne(['nomad', 'consul']),
|
||||||
|
tags: () => {
|
||||||
|
if (!faker.random.boolean()) {
|
||||||
|
return provide(
|
||||||
|
faker.random.number({ min: 0, max: 2 }),
|
||||||
|
faker.hacker.noun.bind(faker.hacker.noun)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Connect: {
|
||||||
|
SidecarService: {
|
||||||
|
Proxy: {
|
||||||
|
Upstreams: [
|
||||||
|
{
|
||||||
|
DestinationName: dasherize(faker.hacker.noun()),
|
||||||
|
LocalBindPort: faker.random.number({ min: 5000, max: 60000 }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -2,13 +2,18 @@ import { Factory } from 'ember-cli-mirage';
|
||||||
import faker from 'nomad-ui/mirage/faker';
|
import faker from 'nomad-ui/mirage/faker';
|
||||||
import { provide } from '../utils';
|
import { provide } from '../utils';
|
||||||
import { dasherize } from '@ember/string';
|
import { dasherize } from '@ember/string';
|
||||||
|
import { DATACENTERS } from '../common';
|
||||||
const ON_UPDATE = ['default', 'ignore', 'ignore_warnings'];
|
import { pickOne } from '../utils';
|
||||||
|
|
||||||
export default Factory.extend({
|
export default Factory.extend({
|
||||||
name: (id) => `${dasherize(faker.hacker.noun())}-${id}-service`,
|
id: () => faker.random.uuid(),
|
||||||
portLabel: () => dasherize(faker.hacker.noun()),
|
address: () => faker.internet.ip(),
|
||||||
onUpdate: faker.helpers.randomize(ON_UPDATE),
|
createIndex: () => faker.random.number(),
|
||||||
|
modifyIndex: () => faker.random.number(),
|
||||||
|
name: () => faker.random.uuid(),
|
||||||
|
serviceName: (id) => `${dasherize(faker.hacker.noun())}-${id}-service`,
|
||||||
|
datacenter: faker.helpers.randomize(DATACENTERS),
|
||||||
|
port: faker.random.number({ min: 5000, max: 60000 }),
|
||||||
tags: () => {
|
tags: () => {
|
||||||
if (!faker.random.boolean()) {
|
if (!faker.random.boolean()) {
|
||||||
return provide(
|
return provide(
|
||||||
|
@ -19,16 +24,38 @@ export default Factory.extend({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Connect: {
|
|
||||||
SidecarService: {
|
afterCreate(service, server) {
|
||||||
Proxy: {
|
if (!service.namespace) {
|
||||||
Upstreams: [
|
const namespace = pickOne(server.db.jobs)?.namespace || 'default';
|
||||||
{
|
service.update({
|
||||||
DestinationName: dasherize(faker.hacker.noun()),
|
namespace,
|
||||||
LocalBindPort: faker.random.number({ min: 5000, max: 60000 }),
|
});
|
||||||
},
|
}
|
||||||
],
|
|
||||||
},
|
if (!service.node) {
|
||||||
},
|
const node = pickOne(server.db.nodes);
|
||||||
|
service.update({
|
||||||
|
nodeId: node.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.db.jobs.findBy({ id: 'service-haver' })) {
|
||||||
|
if (!service.jobId) {
|
||||||
|
service.update({
|
||||||
|
jobId: 'service-haver',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!service.allocId) {
|
||||||
|
const servicedAlloc = pickOne(
|
||||||
|
server.db.allocations.filter((a) => a.jobId === 'service-haver') || []
|
||||||
|
);
|
||||||
|
if (servicedAlloc) {
|
||||||
|
service.update({
|
||||||
|
allocId: servicedAlloc.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -35,6 +35,9 @@ export default Factory.extend({
|
||||||
// Directive used to control whether the task group should have services.
|
// Directive used to control whether the task group should have services.
|
||||||
withServices: false,
|
withServices: false,
|
||||||
|
|
||||||
|
// Whether the tasks themselves should have services.
|
||||||
|
withTaskServices: false,
|
||||||
|
|
||||||
// Directive used to control whether dynamic application sizing recommendations
|
// Directive used to control whether dynamic application sizing recommendations
|
||||||
// should be created.
|
// should be created.
|
||||||
createRecommendations: false,
|
createRecommendations: false,
|
||||||
|
@ -88,8 +91,9 @@ export default Factory.extend({
|
||||||
maybeResources.originalResources = generateResources(resources[idx]);
|
maybeResources.originalResources = generateResources(resources[idx]);
|
||||||
}
|
}
|
||||||
return server.create('task', {
|
return server.create('task', {
|
||||||
taskGroup: group,
|
taskGroupID: group.id,
|
||||||
...maybeResources,
|
...maybeResources,
|
||||||
|
withServices: group.withTaskServices,
|
||||||
volumeMounts: mounts.map((mount) => ({
|
volumeMounts: mounts.map((mount) => ({
|
||||||
Volume: mount,
|
Volume: mount,
|
||||||
Destination: `/${faker.internet.userName()}/${faker.internet.domainWord()}/${faker.internet.color()}`,
|
Destination: `/${faker.internet.userName()}/${faker.internet.domainWord()}/${faker.internet.color()}`,
|
||||||
|
@ -132,12 +136,37 @@ export default Factory.extend({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group.withServices) {
|
if (group.withServices) {
|
||||||
Array(faker.random.number({ min: 1, max: 3 }))
|
const services = server.createList('service-fragment', 5, {
|
||||||
.fill(null)
|
taskGroupId: group.id,
|
||||||
.forEach(() => {
|
|
||||||
server.create('service', {
|
|
||||||
taskGroup: group,
|
taskGroup: group,
|
||||||
|
provider: 'nomad',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
services.push(
|
||||||
|
server.create('service-fragment', {
|
||||||
|
taskGroupId: group.id,
|
||||||
|
taskGroup: group,
|
||||||
|
provider: 'consul',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
services.forEach((fragment) => {
|
||||||
|
server.create('service', {
|
||||||
|
serviceName: fragment.name,
|
||||||
|
id: `${faker.internet.domainWord()}-group-${fragment.name}`,
|
||||||
|
});
|
||||||
|
server.create('service', {
|
||||||
|
serviceName: fragment.name,
|
||||||
|
id: `${faker.internet.domainWord()}-group-${fragment.name}`,
|
||||||
|
});
|
||||||
|
server.create('service', {
|
||||||
|
serviceName: fragment.name,
|
||||||
|
id: `${faker.internet.domainWord()}-group-${fragment.name}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group.update({
|
||||||
|
services,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,12 +2,15 @@ import { Factory } from 'ember-cli-mirage';
|
||||||
import faker from 'nomad-ui/mirage/faker';
|
import faker from 'nomad-ui/mirage/faker';
|
||||||
import { generateResources } from '../common';
|
import { generateResources } from '../common';
|
||||||
import { dasherize } from '@ember/string';
|
import { dasherize } from '@ember/string';
|
||||||
|
import { pickOne } from '../utils';
|
||||||
|
|
||||||
const DRIVERS = ['docker', 'java', 'rkt', 'qemu', 'exec', 'raw_exec'];
|
const DRIVERS = ['docker', 'java', 'rkt', 'qemu', 'exec', 'raw_exec'];
|
||||||
|
|
||||||
export default Factory.extend({
|
export default Factory.extend({
|
||||||
createRecommendations: false,
|
createRecommendations: false,
|
||||||
|
|
||||||
|
withServices: false,
|
||||||
|
|
||||||
// Hidden property used to compute the Summary hash
|
// Hidden property used to compute the Summary hash
|
||||||
groupNames: [],
|
groupNames: [],
|
||||||
|
|
||||||
|
@ -68,5 +71,25 @@ export default Factory.extend({
|
||||||
|
|
||||||
task.save({ recommendationIds: recommendations.mapBy('id') });
|
task.save({ recommendationIds: recommendations.mapBy('id') });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
server.createList('service', 5, {
|
||||||
|
serviceName: fragment.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
task.update({ services });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
6
ui/mirage/models/service-fragment.js
Normal file
6
ui/mirage/models/service-fragment.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { Model, belongsTo } from 'ember-cli-mirage';
|
||||||
|
|
||||||
|
export default Model.extend({
|
||||||
|
taskGroup: belongsTo('task-group'),
|
||||||
|
task: belongsTo('task'),
|
||||||
|
});
|
|
@ -1,5 +1,5 @@
|
||||||
import { Model, belongsTo } from 'ember-cli-mirage';
|
import { Model, belongsTo } from 'ember-cli-mirage';
|
||||||
|
|
||||||
export default Model.extend({
|
export default Model.extend({
|
||||||
taskGroup: belongsTo('task-group'),
|
job: belongsTo('job'),
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,6 +2,6 @@ import { Model, belongsTo, hasMany } from 'ember-cli-mirage';
|
||||||
|
|
||||||
export default Model.extend({
|
export default Model.extend({
|
||||||
job: belongsTo(),
|
job: belongsTo(),
|
||||||
services: hasMany(),
|
services: hasMany('service-fragment'),
|
||||||
tasks: hasMany(),
|
tasks: hasMany(),
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,4 +3,5 @@ import { Model, belongsTo, hasMany } from 'ember-cli-mirage';
|
||||||
export default Model.extend({
|
export default Model.extend({
|
||||||
taskGroup: belongsTo(),
|
taskGroup: belongsTo(),
|
||||||
recommendations: hasMany(),
|
recommendations: hasMany(),
|
||||||
|
services: hasMany('service-fragment'),
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,6 +17,7 @@ export const allScenarios = {
|
||||||
everyFeature,
|
everyFeature,
|
||||||
emptyCluster,
|
emptyCluster,
|
||||||
variableTestCluster,
|
variableTestCluster,
|
||||||
|
servicesTestCluster,
|
||||||
...topoScenarios,
|
...topoScenarios,
|
||||||
...sysbatchScenarios,
|
...sysbatchScenarios,
|
||||||
};
|
};
|
||||||
|
@ -48,6 +49,13 @@ function smallCluster(server) {
|
||||||
server.createList('agent', 3, 'withConsulLink', 'withVaultLink');
|
server.createList('agent', 3, 'withConsulLink', 'withVaultLink');
|
||||||
server.createList('node', 5);
|
server.createList('node', 5);
|
||||||
server.createList('job', 1, { createRecommendations: true });
|
server.createList('job', 1, { createRecommendations: true });
|
||||||
|
server.create('job', {
|
||||||
|
withGroupServices: true,
|
||||||
|
withTaskServices: true,
|
||||||
|
name: 'Service-haver',
|
||||||
|
id: 'service-haver',
|
||||||
|
namespaceId: 'default',
|
||||||
|
});
|
||||||
server.createList('allocFile', 5);
|
server.createList('allocFile', 5);
|
||||||
server.create('allocFile', 'dir', { depth: 2 });
|
server.create('allocFile', 'dir', { depth: 2 });
|
||||||
server.createList('csi-plugin', 2);
|
server.createList('csi-plugin', 2);
|
||||||
|
@ -243,6 +251,138 @@ function variableTestCluster(server) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function servicesTestCluster(server) {
|
||||||
|
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.createList('allocFile', 5);
|
||||||
|
server.create('allocFile', 'dir', { depth: 2 });
|
||||||
|
server.createList('csi-plugin', 2);
|
||||||
|
server.createList('variable', 3);
|
||||||
|
|
||||||
|
const variableLinkedJob = server.db.jobs[0];
|
||||||
|
const variableLinkedGroup = server.db.taskGroups.findBy({
|
||||||
|
jobId: variableLinkedJob.id,
|
||||||
|
});
|
||||||
|
const variableLinkedTask = server.db.tasks.findBy({
|
||||||
|
taskGroupId: variableLinkedGroup.id,
|
||||||
|
});
|
||||||
|
[
|
||||||
|
'a/b/c/foo0',
|
||||||
|
'a/b/c/bar1',
|
||||||
|
'a/b/c/d/e/foo2',
|
||||||
|
'a/b/c/d/e/bar3',
|
||||||
|
'a/b/c/d/e/f/foo4',
|
||||||
|
'a/b/c/d/e/f/g/foo5',
|
||||||
|
'a/b/c/x/y/z/foo6',
|
||||||
|
'a/b/c/x/y/z/bar7',
|
||||||
|
'a/b/c/x/y/z/baz8',
|
||||||
|
'w/x/y/foo9',
|
||||||
|
'w/x/y/z/foo10',
|
||||||
|
'w/x/y/z/bar11',
|
||||||
|
'just some arbitrary file',
|
||||||
|
'another arbitrary file',
|
||||||
|
'another arbitrary file again',
|
||||||
|
].forEach((path) => server.create('variable', { id: path }));
|
||||||
|
|
||||||
|
server.create('variable', {
|
||||||
|
id: `nomad/jobs/${variableLinkedJob.id}/${variableLinkedGroup.name}/${variableLinkedTask.name}`,
|
||||||
|
namespace: variableLinkedJob.namespace,
|
||||||
|
});
|
||||||
|
|
||||||
|
server.create('variable', {
|
||||||
|
id: `nomad/jobs/${variableLinkedJob.id}/${variableLinkedGroup.name}`,
|
||||||
|
namespace: variableLinkedJob.namespace,
|
||||||
|
});
|
||||||
|
|
||||||
|
server.create('variable', {
|
||||||
|
id: `nomad/jobs/${variableLinkedJob.id}`,
|
||||||
|
namespace: variableLinkedJob.namespace,
|
||||||
|
});
|
||||||
|
|
||||||
|
server.create('variable', {
|
||||||
|
id: 'Auto-conflicting Variable',
|
||||||
|
namespace: 'default',
|
||||||
|
});
|
||||||
|
|
||||||
|
// #region evaluations
|
||||||
|
|
||||||
|
// Branching: a single eval that relates to N-1 mutually-unrelated evals
|
||||||
|
const NUM_BRANCHING_EVALUATIONS = 3;
|
||||||
|
Array(NUM_BRANCHING_EVALUATIONS)
|
||||||
|
.fill()
|
||||||
|
.map((_, i) => {
|
||||||
|
return {
|
||||||
|
evaluation: server.create('evaluation', {
|
||||||
|
id: `branching_${i}`,
|
||||||
|
previousEval: i > 0 ? `branching_0` : '',
|
||||||
|
jobID: pickOne(server.db.jobs).id,
|
||||||
|
}),
|
||||||
|
|
||||||
|
evaluationStub: server.create('evaluation-stub', {
|
||||||
|
id: `branching_${i}`,
|
||||||
|
previousEval: i > 0 ? `branching_0` : '',
|
||||||
|
status: 'failed',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.map((x, i, all) => {
|
||||||
|
x.evaluation.update({
|
||||||
|
relatedEvals:
|
||||||
|
i === 0
|
||||||
|
? all.filter((_, j) => j !== 0).map((e) => e.evaluation)
|
||||||
|
: all.filter((_, j) => j !== i).map((e) => e.evaluation),
|
||||||
|
});
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Linear: a long line of N related evaluations
|
||||||
|
const NUM_LINEAR_EVALUATIONS = 20;
|
||||||
|
Array(NUM_LINEAR_EVALUATIONS)
|
||||||
|
.fill()
|
||||||
|
.map((_, i) => {
|
||||||
|
return {
|
||||||
|
evaluation: server.create('evaluation', {
|
||||||
|
id: `linear_${i}`,
|
||||||
|
previousEval: i > 0 ? `linear_${i - 1}` : '',
|
||||||
|
jobID: pickOne(server.db.jobs).id,
|
||||||
|
}),
|
||||||
|
|
||||||
|
evaluationStub: server.create('evaluation-stub', {
|
||||||
|
id: `linear_${i}`,
|
||||||
|
previousEval: i > 0 ? `linear_${i - 1}` : '',
|
||||||
|
nextEval: `linear_${i + 1}`,
|
||||||
|
status: 'failed',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.map((x, i, all) => {
|
||||||
|
x.evaluation.update({
|
||||||
|
relatedEvals: all.filter((_, j) => i !== j).map((e) => e.evaluation),
|
||||||
|
});
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
|
||||||
|
// #endregion evaluations
|
||||||
|
|
||||||
|
const csiAllocations = server.createList('allocation', 5);
|
||||||
|
const volumes = server.schema.csiVolumes.all().models;
|
||||||
|
csiAllocations.forEach((alloc) => {
|
||||||
|
const volume = pickOne(volumes);
|
||||||
|
volume.writeAllocs.add(alloc);
|
||||||
|
volume.readAllocs.add(alloc);
|
||||||
|
volume.save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Due to Mirage performance, large cluster scenarios will be slow
|
// Due to Mirage performance, large cluster scenarios will be slow
|
||||||
function largeCluster(server) {
|
function largeCluster(server) {
|
||||||
server.createList('agent', 5);
|
server.createList('agent', 5);
|
||||||
|
|
6
ui/mirage/serializers/task.js
Normal file
6
ui/mirage/serializers/task.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import ApplicationSerializer from './application';
|
||||||
|
|
||||||
|
export default ApplicationSerializer.extend({
|
||||||
|
embed: true,
|
||||||
|
include: ['services'],
|
||||||
|
});
|
|
@ -133,6 +133,7 @@
|
||||||
"jsonlint": "^1.6.3",
|
"jsonlint": "^1.6.3",
|
||||||
"lint-staged": "^11.2.6",
|
"lint-staged": "^11.2.6",
|
||||||
"loader.js": "^4.7.0",
|
"loader.js": "^4.7.0",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
"lodash.intersection": "^4.4.0",
|
"lodash.intersection": "^4.4.0",
|
||||||
"morgan": "^1.3.2",
|
"morgan": "^1.3.2",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable qunit/require-expect */
|
/* eslint-disable qunit/require-expect */
|
||||||
/* Mirage fixtures are random so we can't expect a set number of assertions */
|
/* Mirage fixtures are random so we can't expect a set number of assertions */
|
||||||
import { run } from '@ember/runloop';
|
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 { assign } from '@ember/polyfills';
|
||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupApplicationTest } from 'ember-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.name, serverService.name);
|
||||||
assert.equal(renderedService.port, serverService.portLabel);
|
assert.equal(renderedService.port, serverService.portLabel);
|
||||||
assert.equal(renderedService.onUpdate, serverService.onUpdate);
|
assert.equal(renderedService.tags, (serverService.tags || []).join(' '));
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -632,3 +617,80 @@ module('Acceptance | allocation detail (preemptions)', function (hooks) {
|
||||||
assert.ok(Allocation.wasPreempted, 'Preempted allocation section is shown');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
54
ui/tests/acceptance/job-services-test.js
Normal file
54
ui/tests/acceptance/job-services-test.js
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { find, findAll, currentURL, settled } from '@ember/test-helpers';
|
||||||
|
import { setupApplicationTest } from 'ember-qunit';
|
||||||
|
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||||
|
import { allScenarios } from '../../mirage/scenarios/default';
|
||||||
|
|
||||||
|
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
|
||||||
|
import Services from 'nomad-ui/tests/pages/jobs/job/services';
|
||||||
|
|
||||||
|
module('Acceptance | job services', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
setupMirage(hooks);
|
||||||
|
hooks.beforeEach(async function () {
|
||||||
|
allScenarios.servicesTestCluster(server);
|
||||||
|
await Services.visit({ id: 'service-haver@default' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Visiting job services', async function (assert) {
|
||||||
|
assert.expect(3);
|
||||||
|
assert.dom('.tabs.is-subnav a.is-active').hasText('Services');
|
||||||
|
assert.dom('.service-list table').exists();
|
||||||
|
await a11yAudit(assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it shows both consul and nomad, and both task and group services', async function (assert) {
|
||||||
|
assert.dom('table tr[data-test-service-provider="consul"]').exists();
|
||||||
|
assert.dom('table tr[data-test-service-provider="nomad"]').exists();
|
||||||
|
assert.dom('table tr[data-test-service-level="task"]').exists();
|
||||||
|
assert.dom('table tr[data-test-service-level="group"]').exists();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Digging into a service', async function (assert) {
|
||||||
|
const expectedNumAllocs = find(
|
||||||
|
'[data-test-service-level="group"]'
|
||||||
|
).getAttribute('data-test-num-allocs');
|
||||||
|
const serviceName = find('[data-test-service-level="group"]').getAttribute(
|
||||||
|
'data-test-service-name'
|
||||||
|
);
|
||||||
|
|
||||||
|
await find('[data-test-service-level="group"] a').click();
|
||||||
|
await settled();
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
currentURL().includes(`services/${serviceName}?level=group`),
|
||||||
|
'correctly traverses to a service instance list'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
findAll('tr[data-test-service-row]').length,
|
||||||
|
expectedNumAllocs,
|
||||||
|
'Same number of alloc rows as the index shows'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -337,6 +337,15 @@ module('Acceptance | keyboard', function (hooks) {
|
||||||
'Shift+ArrowRight takes you to the next tab (Evaluations)'
|
'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', {
|
await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
|
||||||
shiftKey: true,
|
shiftKey: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
|
@ -1,5 +1,3 @@
|
||||||
import { A } from '@ember/array';
|
|
||||||
import EmberObject from '@ember/object';
|
|
||||||
import { findAll, render } from '@ember/test-helpers';
|
import { findAll, render } from '@ember/test-helpers';
|
||||||
import { setupRenderingTest } from 'ember-qunit';
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
import hbs from 'htmlbars-inline-precompile';
|
import hbs from 'htmlbars-inline-precompile';
|
||||||
|
@ -14,28 +12,19 @@ module('Integration | Component | Service Status Bar', function (hooks) {
|
||||||
const component = this;
|
const component = this;
|
||||||
await componentA11yAudit(component, assert);
|
await componentA11yAudit(component, assert);
|
||||||
|
|
||||||
const healthyService = EmberObject.create({
|
const serviceStatus = {
|
||||||
id: '1',
|
success: 1,
|
||||||
status: 'success',
|
pending: 1,
|
||||||
});
|
failure: 1,
|
||||||
|
};
|
||||||
|
|
||||||
const failingService = EmberObject.create({
|
this.set('serviceStatus', serviceStatus);
|
||||||
id: '2',
|
|
||||||
status: 'failing',
|
|
||||||
});
|
|
||||||
|
|
||||||
const pendingService = EmberObject.create({
|
|
||||||
id: '3',
|
|
||||||
status: 'pending',
|
|
||||||
});
|
|
||||||
|
|
||||||
const services = A([healthyService, failingService, pendingService]);
|
|
||||||
this.set('services', services);
|
|
||||||
|
|
||||||
await render(hbs`
|
await render(hbs`
|
||||||
<div class="inline-chart">
|
<div class="inline-chart">
|
||||||
<ServiceStatusBar
|
<ServiceStatusBar
|
||||||
@services={{this.services}}
|
@status={{this.serviceStatus}}
|
||||||
|
@name="peter"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
|
|
5
ui/tests/pages/jobs/job/services.js
Normal file
5
ui/tests/pages/jobs/job/services.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { create, visitable } from 'ember-cli-page-object';
|
||||||
|
|
||||||
|
export default create({
|
||||||
|
visit: visitable('/jobs/:id/services'),
|
||||||
|
});
|
425
ui/tests/unit/controllers/allocations/allocation/index-test.js
Normal file
425
ui/tests/unit/controllers/allocations/allocation/index-test.js
Normal file
|
@ -0,0 +1,425 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'ember-qunit';
|
||||||
|
|
||||||
|
module('Unit | Controller | allocations/allocation/index', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
|
||||||
|
module('#serviceHealthStatuses', function () {
|
||||||
|
test('it groups health service data by service name', function (assert) {
|
||||||
|
let controller = this.owner.lookup(
|
||||||
|
'controller:allocations/allocation/index'
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.set('model', Allocation);
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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']
|
||||||
|
);
|
||||||
|
|
||||||
|
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']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles duplicate names', function (assert) {
|
||||||
|
let controller = this.owner.lookup(
|
||||||
|
'controller:allocations/allocation/index'
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.set('model', Allocation);
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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']
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
controller.servicesWithHealthChecks
|
||||||
|
.findBy('refID', taskDupe.refID)
|
||||||
|
.healthChecks.filter((check) => check.Status === 'success').length,
|
||||||
|
taskDupe.statuses['success']
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
controller.servicesWithHealthChecks
|
||||||
|
.findBy('refID', taskDupe.refID)
|
||||||
|
.healthChecks.filter((check) => check.Status === 'failure').length,
|
||||||
|
taskDupe.statuses['failure']
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
controller.servicesWithHealthChecks
|
||||||
|
.findBy('refID', taskDupe.refID)
|
||||||
|
.healthChecks.filter((check) => check.Status === 'pending').length,
|
||||||
|
taskDupe.statuses['pending']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Using var to hoist this variable to the top of the module
|
||||||
|
var Allocation = {
|
||||||
|
namespace: 'default',
|
||||||
|
name: 'my-alloc',
|
||||||
|
taskGroup: {
|
||||||
|
name: 'fakepy',
|
||||||
|
count: 3,
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
allocatedResources: {
|
||||||
|
Cpu: 100,
|
||||||
|
Memory: 300,
|
||||||
|
MemoryMax: null,
|
||||||
|
Disk: 0,
|
||||||
|
Iops: null,
|
||||||
|
Networks: [
|
||||||
|
{
|
||||||
|
Device: '',
|
||||||
|
CIDR: '',
|
||||||
|
IP: '127.0.0.1',
|
||||||
|
Mode: 'host',
|
||||||
|
MBits: 0,
|
||||||
|
Ports: [
|
||||||
|
{
|
||||||
|
name: 'http',
|
||||||
|
port: 22308,
|
||||||
|
to: 0,
|
||||||
|
isDynamic: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Ports: [
|
||||||
|
{
|
||||||
|
HostIP: '127.0.0.1',
|
||||||
|
Label: 'http',
|
||||||
|
To: 0,
|
||||||
|
Value: 22308,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
healthChecks: {
|
||||||
|
c97fda942e772b43a5a537e5b0c8544c: {
|
||||||
|
Check: 'service: "task-fake-py" check',
|
||||||
|
Group: 'trying-multi-dupes.fakepy[1]',
|
||||||
|
ID: 'c97fda942e772b43a5a537e5b0c8544c',
|
||||||
|
Mode: 'healthiness',
|
||||||
|
Output: 'nomad: http ok',
|
||||||
|
Service: 'task-fake-py',
|
||||||
|
Status: 'success',
|
||||||
|
StatusCode: 200,
|
||||||
|
Task: 'http.server',
|
||||||
|
Timestamp: 1662131947,
|
||||||
|
},
|
||||||
|
'2e1bfc8ecc485ee86b972ae08e890152': {
|
||||||
|
Check: 'task-happy',
|
||||||
|
Group: 'trying-multi-dupes.fakepy[1]',
|
||||||
|
ID: '2e1bfc8ecc485ee86b972ae08e890152',
|
||||||
|
Mode: 'healthiness',
|
||||||
|
Output: 'nomad: http ok',
|
||||||
|
Service: 'task-fake-py',
|
||||||
|
Status: 'success',
|
||||||
|
StatusCode: 200,
|
||||||
|
Task: 'http.server',
|
||||||
|
Timestamp: 1662131949,
|
||||||
|
},
|
||||||
|
'6162723ab20b268c25eda69b400dc9c6': {
|
||||||
|
Check: 'task-sad',
|
||||||
|
Group: 'trying-multi-dupes.fakepy[1]',
|
||||||
|
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',
|
||||||
|
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:
|
||||||
|
'<!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: '2dfe58eb841bdfa704f0ae9ef5b5af5e',
|
||||||
|
Mode: 'readiness',
|
||||||
|
Output: 'nomad: tcp ok',
|
||||||
|
Service: 'web',
|
||||||
|
Status: 'success',
|
||||||
|
Task: 'http.server',
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
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',
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rescheduleEvents: [],
|
||||||
|
job: '["trying-multi-dupes","default"]',
|
||||||
|
node: '5d33384d-8d0f-6a65-743c-2fcc1871b13e',
|
||||||
|
previousAllocation: null,
|
||||||
|
nextAllocation: null,
|
||||||
|
preemptedByAllocation: null,
|
||||||
|
followUpEvaluation: null,
|
||||||
|
};
|
|
@ -14205,7 +14205,7 @@ lodash.isempty@^4.4.0:
|
||||||
lodash.isequal@^4.5.0:
|
lodash.isequal@^4.5.0:
|
||||||
version "4.5.0"
|
version "4.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
|
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
|
||||||
integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
|
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
|
||||||
|
|
||||||
lodash.isfunction@^3.0.9:
|
lodash.isfunction@^3.0.9:
|
||||||
version "3.0.9"
|
version "3.0.9"
|
||||||
|
|
Loading…
Reference in a new issue