-
Notifications
You must be signed in to change notification settings - Fork 2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
UI: add exec terminal #6697
UI: add exec terminal #6697
Changes from 138 commits
7256769
11b0da4
a6aed62
caccb4b
3f9fcb4
9bef4ad
dd4ecf9
1b92691
55ef273
dce93dd
a0a8f88
4bc5838
16160e1
43370ea
922ebd9
0eaf145
1710751
61c09b6
75c2c57
6bd195e
0256014
8747919
3ff8f49
94e50b2
e6c469a
20b7b14
417f317
9cbf6dc
b255650
4a69823
fbc4ce1
fd3d379
56a7080
f7397f1
14b1883
f4dd4df
2524fb6
a1960d2
91a5d38
183c86a
6d90cab
80c4c29
f1cdf22
73668c5
568a860
80f598d
ad1f10b
07330e5
71801d0
5b0ff52
0ffe645
1649705
844de68
f117e1c
967b352
66551ea
5a86b9d
4c2713c
0f6a6fa
123eaab
2f900d2
c87c365
e0f2e60
a47f06e
21e3d5a
8d65a27
840f2ff
7fdf5ea
5de6f89
7e5bcee
551d37a
4ef3388
e138e71
83fef11
50a082d
894c5ec
1429b52
ae3a465
646e52c
240d0e0
6c74c63
34a2db3
b210afe
8c90b76
2524bda
3d32307
877fc04
f87ba12
a7a528d
97b4756
479d9e7
2011286
17a5db5
4c57467
20a9f6d
7afcda2
f24ea68
0566d3e
e40a904
1e1f92f
90fd1a9
846eca7
90c003e
1e75d90
7295eaa
c13b55c
a0c177c
7743dfd
b7109f8
82611c9
31de6d6
e79c191
a448fcf
03d9a3b
4d429d7
1eb598b
d14ec81
309f6c5
795db54
d4f8672
4d05dbe
eda4bc0
9a890d9
41273f5
fbc915b
a5a7629
1c5f8fc
0010213
259b168
3805d4e
39d8c09
4240004
c38397e
53a4c81
4fb8c2b
bc5615c
073aa25
d60076f
41a8046
e1d5918
7c486fa
9e8b5da
53f2972
f98619d
754df0a
14b59af
c39c801
984195b
056f1f6
36ad190
a0335af
4744ddf
8a989c3
415c982
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import Component from '@ember/component'; | ||
import { FitAddon } from 'xterm-addon-fit'; | ||
import $ from 'jquery'; | ||
|
||
export default Component.extend({ | ||
classNames: ['terminal-container'], | ||
|
||
didInsertElement() { | ||
let fitAddon = new FitAddon(); | ||
this.fitAddon = fitAddon; | ||
this.terminal.loadAddon(fitAddon); | ||
|
||
this.terminal.open(this.element.querySelector('.terminal')); | ||
|
||
fitAddon.fit(); | ||
|
||
this._windowResizeHandler = this.windowResizeHandler.bind(this); | ||
$(window).on('resize', this._windowResizeHandler); | ||
}, | ||
|
||
willDestroyElement() { | ||
$(window).off('resize', this._windowResizeHandler); | ||
}, | ||
backspace marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
windowResizeHandler(e) { | ||
this.fitAddon.fit(); | ||
if (this.terminal.resized) { | ||
this.terminal.resized(e); | ||
} | ||
}, | ||
}); |
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); | ||
}, | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import Component from '@ember/component'; | ||
|
||
export default Component.extend({ | ||
tagName: '', | ||
}); |
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; | ||
}, | ||
[] | ||
); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see what you mean now about how hairy this traversal gets. It doesn't make it any better, and in fact I think this is technically (but insignificant at this scale), but this approach with more method chaining might be more readable? const activeStateTaskNames = this.taskGroup.allocations
.mapBy('states')
.reduce((allStates, states) => allStates.concat(states), [])
.filterBy('isActive')
.filter(taskState => taskState.task.taskGroup.name === this.taskGroup.name)
.mapBy('name'); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, I conceptually like reduce but I agree that this is Too Much. I tried replacing with your suggestion and found it was rejecting everything, I don’t understand it:
I don’t know what’s different about it but the collected and flattened objects don’t seem like the true task states…? I would like to do something like what you suggested though, so I opened this issue to help remember, thanks. |
||
|
||
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); | ||
}, | ||
}, | ||
}); |
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', | ||
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.'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not super important but There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wondered about it and decided to follow what the design had but I don’t love the lack of general term 😐 |
||
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('/bin/bash'); | ||
|
||
if (this.commandEditorAdapter) { | ||
this.commandEditorAdapter.destroy(); | ||
} | ||
|
||
this.commandEditorAdapter = new ExecCommandEditorXtermAdapter( | ||
this.terminal, | ||
this.openAndConnectSocket.bind(this), | ||
'/bin/bash' | ||
); | ||
}, | ||
}, | ||
|
||
openAndConnectSocket(command) { | ||
this.set('socketOpen', true); | ||
this.socket = this.sockets.getTaskStateSocket(this.taskState, command); | ||
|
||
new ExecSocketXtermAdapter(this.terminal, this.socket); | ||
}, | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import { inject as service } from '@ember/service'; | ||
import Route from '@ember/routing/route'; | ||
import RSVP from 'rsvp'; | ||
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 RSVP.all([job.get('allocations'), job.get('evaluations')]).then(() => job); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are evaluations used for exec? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hah they indeed are not, the perils of copypaste! I’ve removed it, thank you. |
||
}) | ||
.catch(notifyError(this)); | ||
}, | ||
}); |
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 }); | ||
}, | ||
}); |
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')}"}}` }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a really nice touch. I'm sure this will save someone in the future some time. |
||
} 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}"]`)}` | ||
); | ||
} | ||
}, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this to prevent the errors and socket pool saturation when nomad isn't running in the background? I have also ran into that, but I dunno if I'm ready to remove the proxy from here.
Thoughts on this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
eep sorry, I meant to explain this. Just as I was starting to prototype this, I found that I was completely unable to connect to a websocket through that proxy because the
Origin
didn’t match and the request was rejected by the API. The Javascript socket interface doesn’t give you any control over that for security reasons, so I switched to the technique described here because it gives you fine-grained control. The crux for this feature is this section where the origin gets rewritten in the proxy outside the browser. It’s a shame to lose this simplicity but it seems unavoidable :////////There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wow. TIL. Definitely a shame when things don't just work, but given the amount of oddities in our use of HTTP, I'm surprised the default proxy even got us this far.