Skip to content

Commit

Permalink
UI: add exec terminal (#6697)
Browse files Browse the repository at this point in the history
This connects Xterm.js to a Nomad exec websocket so people
can interact on clients via live sessions. There are buttons on
job, allocation, task group, and task detail pages that open a
popup that lets them edit their shell command and start a
session.

More is to come, as recorded in issues.
  • Loading branch information
backspace authored Mar 24, 2020
1 parent b807491 commit 27df92a
Show file tree
Hide file tree
Showing 50 changed files with 1,633 additions and 74 deletions.
3 changes: 1 addition & 2 deletions ui/.ember-cli
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,5 @@

Setting `disableAnalytics` to true will prevent any data from being sent.
*/
"disableAnalytics": false,
"proxy": "http://127.0.0.1:4646"
"disableAnalytics": false
}
24 changes: 24 additions & 0 deletions ui/app/components/exec-terminal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Component from '@ember/component';
import { FitAddon } from 'xterm-addon-fit';
import WindowResizable from '../mixins/window-resizable';

export default Component.extend(WindowResizable, {
classNames: ['terminal-container'],

didInsertElement() {
let fitAddon = new FitAddon();
this.fitAddon = fitAddon;
this.terminal.loadAddon(fitAddon);

this.terminal.open(this.element.querySelector('.terminal'));

fitAddon.fit();
},

windowResizeHandler(e) {
this.fitAddon.fit();
if (this.terminal.resized) {
this.terminal.resized(e);
}
},
});
36 changes: 36 additions & 0 deletions ui/app/components/exec/open-button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import generateExecUrl from 'nomad-ui/utils/generate-exec-url';
import openExecUrl from 'nomad-ui/utils/open-exec-url';

export default Component.extend({
tagName: '',

router: service(),

actions: {
open() {
openExecUrl(this.generateUrl());
},
},

generateUrl() {
let urlSegments = {
job: this.job.get('name'),
};

if (this.taskGroup) {
urlSegments.taskGroup = this.taskGroup.get('name');
}

if (this.task) {
urlSegments.task = this.task.get('name');
}

if (this.allocation) {
urlSegments.allocation = this.allocation.get('shortId');
}

return generateExecUrl(this.router, urlSegments);
},
});
5 changes: 5 additions & 0 deletions ui/app/components/exec/task-contents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Component from '@ember/component';

export default Component.extend({
tagName: '',
});
66 changes: 66 additions & 0 deletions ui/app/components/exec/task-group-parent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Component from '@ember/component';
import { inject as service } from '@ember/service';
import { computed } from '@ember/object';
import { or } from '@ember/object/computed';
import generateExecUrl from 'nomad-ui/utils/generate-exec-url';
import openExecUrl from 'nomad-ui/utils/open-exec-url';

export default Component.extend({
router: service(),

isOpen: or('clickedOpen', 'currentRouteIsThisTaskGroup'),

currentRouteIsThisTaskGroup: computed('router.currentRoute', function() {
const route = this.router.currentRoute;

if (route.name.includes('task-group')) {
const taskGroupRoute = route.parent;
const execRoute = taskGroupRoute.parent;

return (
execRoute.params.job_name === this.taskGroup.job.name &&
taskGroupRoute.params.task_group_name === this.taskGroup.name
);
} else {
return false;
}
}),

tasksWithRunningStates: computed('taskGroup', function() {
const activeStateTaskNames = this.taskGroup.allocations.reduce(
(activeStateTaskNames, allocation) => {
activeStateTaskNames = activeStateTaskNames.concat(
allocation.states
.filter(
taskState =>
taskState.isActive && taskState.task.taskGroup.name === this.taskGroup.name
)
.mapBy('name')
);

return activeStateTaskNames;
},
[]
);

return this.taskGroup.tasks.filter(task => activeStateTaskNames.includes(task.name));
}),

clickedOpen: false,

actions: {
toggleOpen() {
this.toggleProperty('clickedOpen');
},

openInNewWindow(job, taskGroup, task) {
let url = generateExecUrl(this.router, {
job: job.name,
taskGroup: taskGroup.name,
task: task.name,
});

openExecUrl(url);
},
},
});
81 changes: 81 additions & 0 deletions ui/app/controllers/exec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { inject as service } from '@ember/service';
import Controller from '@ember/controller';
import { filterBy, mapBy, uniq } from '@ember/object/computed';
import escapeTaskName from 'nomad-ui/utils/escape-task-name';
import ExecCommandEditorXtermAdapter from 'nomad-ui/utils/classes/exec-command-editor-xterm-adapter';
import ExecSocketXtermAdapter from 'nomad-ui/utils/classes/exec-socket-xterm-adapter';

import { Terminal } from 'xterm-vendor';

const ANSI_UI_GRAY_400 = '\x1b[38;2;142;150;163m';
const ANSI_WHITE = '\x1b[0m';

export default Controller.extend({
sockets: service(),
system: service(),

queryParams: ['allocation'],

command: '/bin/bash', // Issue to improve: https://github.com/hashicorp/nomad/issues/7469
socketOpen: false,
taskState: null,

runningAllocations: filterBy('model.allocations', 'isRunning'),
runningTaskGroups: mapBy('runningAllocations', 'taskGroup'),
uniqueRunningTaskGroups: uniq('runningTaskGroups'),

init() {
this._super(...arguments);

this.terminal = new Terminal({ fontFamily: 'monospace', fontWeight: '400' });
window.execTerminal = this.terminal; // Issue to improve: https://github.com/hashicorp/nomad/issues/7457

this.terminal.write(ANSI_UI_GRAY_400);
this.terminal.writeln('Select a task to start your session.');
},

actions: {
setTaskState({ allocationSpecified, taskState }) {
this.set('taskState', taskState);

this.terminal.write(ANSI_UI_GRAY_400);
this.terminal.writeln('');

if (!allocationSpecified) {
this.terminal.writeln(
'Multiple instances of this task are running. The allocation below was selected by random draw.'
);
this.terminal.writeln('');
}

this.terminal.writeln('Customize your command, then hit ‘return’ to run.');
this.terminal.writeln('');
this.terminal.write(
`$ nomad alloc exec -i -t -task ${escapeTaskName(taskState.name)} ${
taskState.allocation.shortId
} `
);

this.terminal.write(ANSI_WHITE);

this.terminal.write(this.command);

if (this.commandEditorAdapter) {
this.commandEditorAdapter.destroy();
}

this.commandEditorAdapter = new ExecCommandEditorXtermAdapter(
this.terminal,
this.openAndConnectSocket.bind(this),
this.command
);
},
},

openAndConnectSocket(command) {
this.set('socketOpen', true);
this.socket = this.sockets.getTaskStateSocket(this.taskState, command);

new ExecSocketXtermAdapter(this.terminal, this.socket);
},
});
6 changes: 6 additions & 0 deletions ui/app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ const Router = EmberRouter.extend({
});

Router.map(function() {
this.route('exec', { path: '/exec/:job_name' }, function() {
this.route('task-group', { path: '/:task_group_name' }, function() {
this.route('task', { path: '/:task_name' });
});
});

this.route('jobs', function() {
this.route('run');
this.route('job', { path: '/:job_name' }, function() {
Expand Down
26 changes: 26 additions & 0 deletions ui/app/routes/exec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import notifyError from 'nomad-ui/utils/notify-error';

// copied from jobs/job, issue to improve: https://github.com/hashicorp/nomad/issues/7458

export default Route.extend({
store: service(),
token: service(),

serialize(model) {
return { job_name: model.get('plainId') };
},

model(params, transition) {
const namespace = transition.to.queryParams.namespace || this.get('system.activeNamespace.id');
const name = params.job_name;
const fullId = JSON.stringify([name, namespace || 'default']);
return this.store
.findRecord('job', fullId, { reload: true })
.then(job => {
return job.get('allocations').then(() => job);
})
.catch(notifyError(this));
},
});
39 changes: 39 additions & 0 deletions ui/app/routes/exec/task-group/task.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';

export default Route.extend({
store: service(),

model({ task_name }) {
const allocationQueryParam = this.paramsFor('exec').allocation;

return this.modelFor('exec').allocations.then(allocations => {
let allocation;

if (allocationQueryParam) {
allocation = allocations.findBy('shortId', allocationQueryParam);
} else {
allocation = allocations.find(allocation =>
allocation.states
.filterBy('isActive')
.mapBy('name')
.includes(task_name)
);
}

return {
allocation,
allocationSpecified: allocationQueryParam ? true : false,
taskState: allocation.states.find(state => state.name === task_name),
};
});
},

afterModel(model) {
this.controllerFor('exec').send('setTaskState', model);
},

setupController(controller, { allocation, taskState }) {
controller.setProperties({ allocation, taskState });
},
});
36 changes: 36 additions & 0 deletions ui/app/services/sockets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Service from '@ember/service';
import config from 'nomad-ui/config/environment';
import { getOwner } from '@ember/application';

export default Service.extend({
getTaskStateSocket(taskState, command) {
const mirageEnabled =
config['ember-cli-mirage'] && config['ember-cli-mirage'].enabled !== false;

if (mirageEnabled) {
return new Object({
messageDisplayed: false,

send(e) {
if (!this.messageDisplayed) {
this.messageDisplayed = true;
this.onmessage({ data: `{"stdout":{"data":"${btoa('unsupported in Mirage\n\r')}"}}` });
} else {
this.onmessage({ data: e.replace('stdin', 'stdout') });
}
},
});
} else {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const applicationAdapter = getOwner(this).lookup('adapter:application');
const prefix = `${applicationAdapter.host ||
window.location.host}/${applicationAdapter.urlPrefix()}`;

return new WebSocket(
`${protocol}//${prefix}/client/allocation/${taskState.allocation.id}` +
`/exec?task=${taskState.name}&tty=true` +
`&command=${encodeURIComponent(`["${command}"]`)}`
);
}
},
});
1 change: 1 addition & 0 deletions ui/app/styles/components.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
@import './components/ember-power-select';
@import './components/empty-message';
@import './components/error-container';
@import './components/exec';
@import './components/fs-explorer';
@import './components/gutter';
@import './components/gutter-toggle';
Expand Down
Loading

0 comments on commit 27df92a

Please sign in to comment.