UI: add Consul Connect features (#6108)

This commit is contained in:
Buck Doyle 2019-09-04 09:39:56 -05:00 committed by GitHub
parent 6d73ca0cfb
commit b5e5798e54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 353 additions and 23 deletions

View file

@ -8,6 +8,7 @@ IMPROVEMENTS:
* api: Added follow parameter to file streaming endpoint to support older browsers [[GH-6049](https://github.com/hashicorp/nomad/issues/6049)]
* cli: Added `-dev-connect` parameter to support running in dev mode with Consul Connect [[GH-6126](https://github.com/hashicorp/nomad/issues/6126)]
* metrics: Add job status (pending, running, dead) metrics [[GH-6003](https://github.com/hashicorp/nomad/issues/6003)]
* ui: Added Consul Connect features [[GH-6108](https://github.com/hashicorp/nomad/pull/6108)]
* ui: Added creation time to evaluations table [[GH-6050](https://github.com/hashicorp/nomad/pull/6050)]
BUG FIXES:

View file

@ -0,0 +1,5 @@
import Component from '@ember/component';
export default Component.extend({
tagName: '',
});

View file

@ -29,6 +29,12 @@ export default Controller.extend(Sortable, {
return null;
}),
network: alias('model.allocatedResources.networks.firstObject'),
services: computed('model.taskGroup.services.@each.name', function() {
return this.get('model.taskGroup.services').sortBy('name');
}),
onDismiss() {
this.set('error', null);
},

View file

@ -25,6 +25,7 @@ export default Model.extend({
name: attr('string'),
taskGroupName: attr('string'),
resources: fragment('resources'),
allocatedResources: fragment('resources'),
jobVersion: attr('number'),
modifyIndex: attr('number'),

View file

@ -0,0 +1,6 @@
import Fragment from 'ember-data-model-fragments/fragment';
import { fragment } from 'ember-data-model-fragments/attributes';
export default Fragment.extend({
sidecarService: fragment('sidecar-service'),
});

View file

@ -6,7 +6,7 @@ export default Fragment.extend({
device: attr('string'),
cidr: attr('string'),
ip: attr('string'),
mode: attr('string'),
mbits: attr('number'),
reservedPorts: array(),
dynamicPorts: array(),
ports: array(),
});

10
ui/app/models/service.js Normal file
View file

@ -0,0 +1,10 @@
import attr from 'ember-data/attr';
import Fragment from 'ember-data-model-fragments/fragment';
import { fragment } from 'ember-data-model-fragments/attributes';
export default Fragment.extend({
name: attr('string'),
portLabel: attr('string'),
tags: attr(),
connect: fragment('consul-connect'),
});

View file

@ -0,0 +1,7 @@
import Fragment from 'ember-data-model-fragments/fragment';
import attr from 'ember-data/attr';
export default Fragment.extend({
destinationName: attr('string'),
localBindPort: attr('string'),
});

View file

@ -0,0 +1,6 @@
import Fragment from 'ember-data-model-fragments/fragment';
import { fragmentArray } from 'ember-data-model-fragments/attributes';
export default Fragment.extend({
upstreams: fragmentArray('sidecar-proxy-upstream'),
});

View file

@ -0,0 +1,6 @@
import Fragment from 'ember-data-model-fragments/fragment';
import { fragment } from 'ember-data-model-fragments/attributes';
export default Fragment.extend({
proxy: fragment('sidecar-proxy'),
});

View file

@ -14,10 +14,10 @@ export default Fragment.extend({
tasks: fragmentArray('task'),
services: fragmentArray('service'),
drivers: computed('tasks.@each.driver', function() {
return this.tasks
.mapBy('driver')
.uniq();
return this.tasks.mapBy('driver').uniq();
}),
allocations: computed('job.allocations.@each.taskGroup', function() {

View file

@ -9,6 +9,7 @@ export default Fragment.extend({
name: attr('string'),
state: attr('string'),
kind: attr('string'),
startedAt: attr('date'),
finishedAt: attr('date'),
failed: attr('boolean'),
@ -16,6 +17,10 @@ export default Fragment.extend({
isActive: none('finishedAt'),
isRunning: and('isActive', 'allocation.isRunning'),
isConnectProxy: computed('kind', function() {
return (this.kind || '').startsWith('connect-proxy:');
}),
task: computed('allocation.taskGroup.tasks.[]', function() {
const tasks = this.get('allocation.taskGroup.tasks');
return tasks && tasks.findBy('name', this.name);

View file

@ -49,6 +49,9 @@ export default ApplicationSerializer.extend({
hash.PreemptedByAllocationID = hash.PreemptedByAllocation || null;
hash.WasPreempted = !!hash.PreemptedByAllocationID;
// When present, the resources are nested under AllocatedResources.Shared
hash.AllocatedResources = hash.AllocatedResources && hash.AllocatedResources.Shared;
return this._super(typeHash, hash);
},
});

View file

@ -15,6 +15,22 @@ export default ApplicationSerializer.extend({
hash.IP = `[${ip}]`;
}
const reservedPorts = (hash.ReservedPorts || []).map(port => ({
name: port.Label,
port: port.Value,
to: port.To,
isDynamic: false,
}));
const dynamicPorts = (hash.DynamicPorts || []).map(port => ({
name: port.Label,
port: port.Value,
to: port.To,
isDynamic: true,
}));
hash.Ports = reservedPorts.concat(dynamicPorts).sortBy('name');
return this._super(...arguments);
},
});

View file

@ -0,0 +1,15 @@
import ApplicationSerializer from './application';
export default ApplicationSerializer.extend({
attrs: {
connect: 'Connect',
},
normalize(typeHash, hash) {
if (!hash.Tags) {
hash.Tags = [];
}
return this._super(typeHash, hash);
},
});

View file

@ -115,6 +115,66 @@
</div>
</div>
{{#if network.ports.length}}
<div class="boxed-section" data-test-allocation-ports>
<div class="boxed-section-head">
Ports
</div>
<div class="boxed-section-body is-full-bleed">
{{#list-table source=network.ports as |t|}}
{{#t.head}}
<th class="is-2">Name</th>
<th class="is-1">Dynamic?</th>
<th>Host Address</th>
<th>Mapped Port</th>
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-allocation-port>
<td data-test-allocation-port-name>{{row.model.name}}</td>
<td data-test-allocation-port-is-dynamic>{{if row.model.isDynamic "Yes" "No"}}</td>
<td data-test-allocation-port-address>
<a href="http://{{network.ip}}:{{row.model.port}}" target="_blank" rel="noopener noreferrer">{{network.ip}}:{{row.model.port}}</a>
</td>
<td data-test-allocation-port-to>{{row.model.to}}</td>
</tr>
{{/t.body}}
{{/list-table}}
</div>
</div>
{{/if}}
{{#if services.length}}
<div class="boxed-section">
<div class="boxed-section-head">
Services
</div>
<div class="boxed-section-body is-full-bleed">
{{#list-table source=services as |t|}}
{{#t.head}}
<th class="is-2">Name</th>
<th class="is-1">Port</th>
<td>Tags</td>
<td>Connect?</td>
<td>Upstreams</td>
{{/t.head}}
{{#t.body as |row|}}
<tr data-test-service>
<td data-test-service-name>{{row.model.name}}</td>
<td data-test-service-port>{{row.model.portLabel}}</td>
<td data-test-service-tags>{{join ", " row.model.tags}}</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}}
</td>
</tr>
{{/t.body}}
{{/list-table}}
</div>
</div>
{{/if}}
{{#if model.hasRescheduleEvents}}
<div class="boxed-section" data-test-reschedule-events>
<div class="boxed-section-head is-hollow">

View file

@ -17,7 +17,10 @@
<h1 class="title" data-test-title>
{{model.name}}
<span class="bumper-left tag {{model.stateClass}}" data-test-state>{{model.state}}</span>
{{#if model.isConnectProxy}}
{{proxy-tag class="bumper-left"}}
{{/if}}
<span class="{{unless model.isConnectProxy "bumper-left"}} tag {{model.stateClass}}" data-test-state>{{model.state}}</span>
{{#if model.isRunning}}
{{two-step-button
data-test-restart
@ -74,13 +77,13 @@
</div>
</div>
{{#if ports.length}}
{{#if network.ports.length}}
<div class="boxed-section" data-test-task-addresses>
<div class="boxed-section-head">
Addresses
</div>
<div class="boxed-section-body is-full-bleed">
{{#list-table source=ports as |t|}}
{{#list-table source=network.ports as |t|}}
{{#t.head}}
<th class="is-1">Dynamic?</th>
<th class="is-2">Name</th>

View file

@ -0,0 +1,3 @@
{{#freestyle-usage "proxy-tag" title="Proxy Tag"}}
{{proxy-tag}}
{{/freestyle-usage}}

View file

@ -0,0 +1,3 @@
<span class="badge is-light tooltip" role="tooltip" aria-label="Consul Connect proxy task" data-test-proxy-tag>
Proxy
</span>

View file

@ -5,9 +5,12 @@
</span>
{{/if}}
</td>
<td data-test-name>
<td data-test-name class="nowrap">
{{#link-to "allocations.allocation.task" task.allocation task class="is-primary"}}
{{task.name}}
{{#if task.isConnectProxy}}
{{proxy-tag class="bumper-left"}}
{{/if}}
{{/link-to}}
</td>
<td data-test-state>{{task.state}}</td>
@ -22,16 +25,10 @@
<td data-test-ports>
<ul>
{{#with task.resources.networks.firstObject as |network|}}
{{#each network.reservedPorts as |port|}}
{{#each network.ports as |port|}}
<li data-test-port>
<strong>{{port.Label}}:</strong>
<a href="http://{{network.ip}}:{{port.Value}}" target="_blank" rel="noopener noreferrer">{{network.ip}}:{{port.Value}}</a>
</li>
{{/each}}
{{#each network.dynamicPorts as |port|}}
<li>
<strong>{{port.Label}}:</strong>
<a href="http://{{network.ip}}:{{port.Value}}" target="_blank" rel="noopener noreferrer">{{network.ip}}:{{port.Value}}</a>
<strong>{{port.name}}:</strong>
<a href="http://{{network.ip}}:{{port.port}}" target="_blank" rel="noopener noreferrer">{{network.ip}}:{{port.port}}</a>
</li>
{{/each}}
{{/with}}

View file

@ -83,6 +83,10 @@
{{freestyle/sg-page-title}}
{{/section.subsection}}
{{#section.subsection name="Proxy Tag"}}
{{freestyle/sg-proxy-tag}}
{{/section.subsection}}
{{#section.subsection name="Search box"}}
{{freestyle/sg-search-box}}
{{/section.subsection}}

View file

@ -11,6 +11,8 @@ const IOPS_RESERVATIONS = [100000, 250000, 500000, 1000000, 10000000, 20000000];
IOPS_RESERVATIONS.push(...Array(1000).fill(0));
DISK_RESERVATIONS.push(...Array(500).fill(0));
const NETWORK_MODES = ['bridge', 'host'];
export const DATACENTERS = provide(
15,
(n, i) => `${faker.address.countryCode().toLowerCase()}${i}`
@ -39,6 +41,7 @@ export function generateNetworks(options = {}) {
CIDR: '',
IP: faker.internet.ip(),
MBits: 10,
Mode: faker.random.arrayElement(NETWORK_MODES),
ReservedPorts: Array(
faker.random.number({
min: options.minPorts != null ? options.minPorts : 0,
@ -49,6 +52,7 @@ export function generateNetworks(options = {}) {
.map(() => ({
Label: faker.hacker.noun(),
Value: faker.random.number({ min: 5000, max: 60000 }),
To: faker.random.number({ min: 5000, max: 60000 }),
})),
DynamicPorts: Array(
faker.random.number({
@ -60,6 +64,7 @@ export function generateNetworks(options = {}) {
.map(() => ({
Label: faker.hacker.noun(),
Value: faker.random.number({ min: 5000, max: 60000 }),
To: faker.random.number({ min: 5000, max: 60000 }),
})),
}));
}

View file

@ -2,6 +2,7 @@ import Ember from 'ember';
import moment from 'moment';
import { Factory, faker, trait } from 'ember-cli-mirage';
import { provide, pickOne } from '../utils';
import { generateResources } from '../common';
const UUIDS = provide(100, faker.random.uuid.bind(faker.random));
const CLIENT_STATUSES = ['pending', 'running', 'complete', 'failed', 'lost'];
@ -65,6 +66,14 @@ export default Factory.extend({
},
}),
withAllocatedResources: trait({
allocatedResources: () => {
return {
Shared: generateResources({ networks: { minPorts: 2 } }),
};
},
}),
rescheduleAttempts: 0,
rescheduleSuccess: false,

View file

@ -98,6 +98,9 @@ export default Factory.extend({
// When true, allocations for this job will fail and reschedule, randomly succeeding or not
withRescheduling: false,
// When true, task groups will have services
withGroupServices: false,
// When true, only task groups and allocations are made
shallow: false,
@ -118,6 +121,7 @@ export default Factory.extend({
job,
createAllocations: job.createAllocations,
withRescheduling: job.withRescheduling,
withServices: job.withGroupServices,
shallow: job.shallow,
});

View file

@ -0,0 +1,29 @@
import { Factory, faker } from 'ember-cli-mirage';
import { provide } from '../utils';
export default Factory.extend({
name: id => `${faker.hacker.noun().dasherize()}-${id}-service`,
portLabel: () => faker.hacker.noun().dasherize(),
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: faker.hacker.noun().dasherize(),
LocalBindPort: faker.random.number({ min: 5000, max: 60000 }),
},
],
},
},
},
});

View file

@ -20,6 +20,9 @@ export default Factory.extend({
// and reschedule, creating reschedule events.
withRescheduling: false,
// Directive used to control whether the task group should have services.
withServices: false,
// When true, only creates allocations
shallow: false,
@ -60,5 +63,15 @@ export default Factory.extend({
}
});
}
if (group.withServices) {
Array(faker.random.number({ min: 1, max: 3 }))
.fill(null)
.forEach(() => {
server.create('service', {
task_group: group,
});
});
}
},
});

View file

@ -6,6 +6,7 @@ const REF_TIME = new Date();
export default Factory.extend({
name: () => '!!!this should be set by the allocation that owns this task state!!!',
state: faker.list.random(...TASK_STATUSES),
kind: null,
startedAt: faker.date.past(2 / 365, REF_TIME),
finishedAt() {
if (['pending', 'running'].includes(this.state)) {

View file

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

View file

@ -2,5 +2,6 @@ import { Model, belongsTo, hasMany } from 'ember-cli-mirage';
export default Model.extend({
job: belongsTo(),
services: hasMany(),
tasks: hasMany(),
});

View file

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

View file

@ -19,8 +19,14 @@ module('Acceptance | allocation detail', function(hooks) {
server.create('agent');
node = server.create('node');
job = server.create('job', { groupsCount: 1, createAllocations: false });
allocation = server.create('allocation', 'withTaskWithPorts', { clientStatus: 'running' });
job = server.create('job', {
groupsCount: 1,
withGroupServices: true,
createAllocations: false,
});
allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', {
clientStatus: 'running',
});
// Make sure the node has an unhealthy driver
node.update({
@ -134,6 +140,21 @@ module('Acceptance | allocation detail', function(hooks) {
assert.ok(Allocation.firstUnhealthyTask().hasUnhealthyDriver, 'Warning is shown');
});
test('proxy task has a proxy tag', async function(assert) {
allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', {
clientStatus: 'running',
});
allocation.task_states.models.forEach(task => {
task.kind = 'connect-proxy:task';
task.save();
});
await Allocation.visit({ id: allocation.id });
assert.ok(Allocation.tasks[0].hasProxyTag);
});
test('when there are no tasks, an empty state is shown', async function(assert) {
// Make sure the allocation is pending in order to ensure there are no tasks
allocation = server.create('allocation', 'withTaskWithPorts', { clientStatus: 'pending' });
@ -146,6 +167,46 @@ module('Acceptance | allocation detail', function(hooks) {
assert.notOk(Allocation.hasRescheduleEvents, 'Reschedule Events section exists');
});
test('ports are listed', async function(assert) {
const serverNetwork = allocation.allocatedResources.Shared.Networks[0];
const allServerPorts = serverNetwork.ReservedPorts.concat(serverNetwork.DynamicPorts);
allServerPorts.sortBy('Label').forEach((serverPort, index) => {
const renderedPort = Allocation.ports[index];
assert.equal(
renderedPort.dynamic,
serverNetwork.ReservedPorts.includes(serverPort) ? 'No' : 'Yes'
);
assert.equal(renderedPort.name, serverPort.Label);
assert.equal(renderedPort.address, `${serverNetwork.IP}:${serverPort.Value}`);
assert.equal(renderedPort.to, serverPort.To);
});
});
test('services are listed', async function(assert) {
const taskGroup = server.schema.taskGroups.findBy({ name: allocation.taskGroup });
assert.equal(Allocation.services.length, taskGroup.services.length);
taskGroup.services.models.sortBy('name').forEach((serverService, index) => {
const renderedService = Allocation.services[index];
assert.equal(renderedService.name, serverService.name);
assert.equal(renderedService.port, serverService.portLabel);
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);
});
});
test('when the allocation is not found, an error message is shown, but the URL persists', async function(assert) {
await Allocation.visit({ id: 'not-a-real-allocation' });

View file

@ -23,7 +23,7 @@ module('Acceptance | task detail', function(hooks) {
});
test('/allocation/:id/:task_name should name the task and list high-level task information', async function(assert) {
assert.ok(Task.title.includes(task.name), 'Task name');
assert.ok(Task.title.text.includes(task.name), 'Task name');
assert.ok(Task.state.includes(task.state), 'Task state');
assert.ok(
@ -307,3 +307,25 @@ module('Acceptance | task detail (not running)', function(hooks) {
assert.equal(Task.resourceEmptyMessage, "Task isn't running", 'Empty message is appropriate');
});
});
module('Acceptance | proxy task detail', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function() {
server.create('agent');
server.create('node');
server.create('job', { createAllocations: false });
allocation = server.create('allocation', 'withTaskWithPorts', { clientStatus: 'running' });
task = allocation.task_states.models[0];
task.kind = 'connect-proxy:task';
task.save();
await Task.visit({ id: allocation.id, name: task.name });
});
test('a proxy tag is shown', async function(assert) {
assert.ok(Task.title.proxyTag.isPresent);
});
});

View file

@ -44,6 +44,7 @@ export default create({
ports: text('[data-test-ports]'),
hasUnhealthyDriver: isPresent('[data-test-icon="unhealthy-driver"]'),
hasProxyTag: isPresent('[data-test-proxy-tag]'),
clickLink: clickable('[data-test-name] a'),
clickRow: clickable('[data-test-name]'),
@ -75,6 +76,21 @@ export default create({
preempted: isPresent('[data-test-preemptions]'),
...allocations('[data-test-preemptions] [data-test-allocation]', 'preemptions'),
ports: collection('[data-test-allocation-port]', {
dynamic: text('[data-test-allocation-port-is-dynamic]'),
name: text('[data-test-allocation-port-name]'),
address: text('[data-test-allocation-port-address]'),
to: text('[data-test-allocation-port-to]'),
}),
services: collection('[data-test-service]', {
name: text('[data-test-service-name]'),
port: text('[data-test-service-port]'),
tags: text('[data-test-service-tags]'),
connect: text('[data-test-service-connect]'),
upstreams: text('[data-test-service-upstreams]'),
}),
error: {
isShown: isPresent('[data-test-error]'),
title: text('[data-test-error-title]'),

View file

@ -13,7 +13,14 @@ import twoStepButton from 'nomad-ui/tests/pages/components/two-step-button';
export default create({
visit: visitable('/allocations/:id/:name'),
title: text('[data-test-title]'),
title: {
scope: '[data-test-title]',
proxyTag: {
scope: '[data-test-proxy-tag]',
},
},
state: text('[data-test-state]'),
startedAt: text('[data-test-started-at]'),