Merge pull request #14408 from hashicorp/f-ui/service-discovery

Service discovery in the Nomad UI
This commit is contained in:
Phil Renaud 2022-09-07 13:40:09 -04:00 committed by GitHub
commit 59cf87ca6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1772 additions and 116 deletions

3
.changelog/14408.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
ui: add service discovery, along with health checks, to job and allocation routes
```

View file

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

View file

@ -0,0 +1,5 @@
import Watchable from './watchable';
import classic from 'ember-classic-decorator';
@classic
export default class ServiceAdapter extends Watchable {}

View file

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

View file

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

View file

@ -0,0 +1,41 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
export default class AllocationServiceSidebarComponent extends Component {
@service store;
get isSideBarOpen() {
return !!this.args.service;
}
keyCommands = [
{
label: 'Close Evaluations Sidebar',
pattern: ['Escape'],
action: () => this.args.fns.closeSidebar(),
},
];
get service() {
return this.store.query('service-fragment', { refID: this.args.serviceID });
}
get address() {
const port = this.args.allocation?.allocatedResources?.ports?.findBy(
'label',
this.args.service.portLabel
);
if (port) {
return `${port.hostIp}:${port.value}`;
} else {
return null;
}
}
get aggregateStatus() {
return this.args.service?.mostRecentChecks?.any(
(check) => check.Status === 'failure'
)
? 'Unhealthy'
: 'Healthy';
}
}

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import { watchRecord } from 'nomad-ui/utils/properties/watch';
import messageForError from 'nomad-ui/utils/message-from-adapter-error'; import 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
} }

View file

@ -0,0 +1,3 @@
import Controller from '@ember/controller';
export default class JobsJobServicesController extends Controller {}

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1,3 @@
import Route from '@ember/routing/route';
export default class JobsJobServicesIndexRoute extends Route {}

View 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 || [] };
}
}

View file

@ -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`, {

View 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'];
}

View file

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

View file

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

View file

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

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

View file

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

View file

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

View 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>

View file

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

View file

@ -0,0 +1,3 @@
{{page-title "Job " @model.name " services"}}
<JobSubnav @job={{@model}} />
{{outlet}}

View 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>

View 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 &amp; 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>

View file

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

View file

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

View file

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

View 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 }),
},
],
},
},
},
});

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
import { Model, belongsTo } from 'ember-cli-mirage';
export default Model.extend({
taskGroup: belongsTo('task-group'),
task: belongsTo('task'),
});

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
embed: true,
include: ['services'],
});

View file

@ -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",

View file

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

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

View file

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

View file

@ -0,0 +1,38 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
module(
'Integration | Component | allocation-service-sidebar',
function (hooks) {
setupRenderingTest(hooks);
test('it supports basic open/close states', async function (assert) {
assert.expect(7);
await componentA11yAudit(this.element, assert);
this.set('closeSidebar', () => this.set('service', null));
this.set('service', { name: 'Funky Service' });
await render(
hbs`<AllocationServiceSidebar @service={{this.service}} @fns={{hash closeSidebar=this.closeSidebar}} />`
);
assert.dom('h1').includesText('Funky Service');
assert.dom('.sidebar').hasClass('open');
this.set('service', null);
await render(
hbs`<AllocationServiceSidebar @service={{this.service}} @fns={{hash closeSidebar=this.closeSidebar}} />`
);
assert.dom(this.element).hasText('');
assert.dom('.sidebar').doesNotHaveClass('open');
this.set('service', { name: 'Funky Service' });
await click('[data-test-close-service-sidebar]');
assert.dom(this.element).hasText('');
assert.dom('.sidebar').doesNotHaveClass('open');
});
}
);

View file

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

View file

@ -0,0 +1,5 @@
import { create, visitable } from 'ember-cli-page-object';
export default create({
visit: visitable('/jobs/:id/services'),
});

View 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,
};

View file

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