dashboard/shell/components/nav/WindowManager/ContainerShell.vue

489 lines
11 KiB
Vue

<script>
import { allHash } from '@shell/utils/promise';
import { addParams } from '@shell/utils/url';
import { base64Decode, base64Encode } from '@shell/utils/crypto';
import Select from '@shell/components/form/Select';
import { NODE } from '@shell/config/types';
import Socket, {
EVENT_CONNECTED,
EVENT_CONNECTING,
EVENT_DISCONNECTED,
EVENT_MESSAGE,
// EVENT_FRAME_TIMEOUT,
EVENT_CONNECT_ERROR,
} from '@shell/utils/socket';
import Window from './Window';
const commands = {
linux: [
'/bin/sh',
'-c',
'TERM=xterm-256color; export TERM; [ -x /bin/bash ] && ([ -x /usr/bin/script ] && /usr/bin/script -q -c "/bin/bash" /dev/null || exec /bin/bash) || exec /bin/sh',
],
windows: ['cmd']
};
export default {
components: { Window, Select },
props: {
// The definition of the tab itself
tab: {
type: Object,
required: true,
},
// Is this tab currently displayed
active: {
type: Boolean,
required: true,
},
// The height of the window
height: {
type: Number,
required: true,
},
// The width of the window
width: {
type: Number,
default: undefined,
},
// The pod to connect to
pod: {
type: Object,
required: true,
},
// The container in the pod to initially show
initialContainer: {
type: String,
default: null,
},
// Runs this command immediately after connecting
commandOnFirstConnect: {
type: String,
default: null
}
},
data() {
return {
container: this.initialContainer || this.pod?.defaultContainerName,
socket: null,
terminal: null,
fitAddon: null,
searchAddon: null,
webglAddon: null,
isOpen: false,
isOpening: false,
backlog: [],
node: null,
keepAliveTimer: null,
errorMsg: '',
backupShells: ['linux', 'windows'],
os: undefined,
retries: 0
};
},
computed: {
xtermConfig() {
return {
allowProposedApi: true,
cursorBlink: true,
useStyle: true,
fontSize: 12,
};
},
containerChoices() {
return this.pod?.spec?.containers?.map((x) => x.name) || [];
},
},
watch: {
container() {
this.connect();
},
height() {
this.fit();
},
width() {
this.fit();
},
},
beforeDestroy() {
clearInterval(this.keepAliveTimer);
this.cleanup();
},
async mounted() {
const nodeId = this.pod.spec?.nodeName;
const schema = this.$store.getters[`cluster/schemaFor`](NODE);
if (schema) {
await this.$store.dispatch('cluster/find', { type: NODE, id: nodeId });
}
await this.setupTerminal();
await this.connect();
clearInterval(this.keepAliveTimer);
this.keepAliveTimer = setInterval(() => {
this.fit();
}, 60 * 1000);
},
methods: {
async setupTerminal() {
const docStyle = getComputedStyle(document.querySelector('body'));
const xterm = await import(/* webpackChunkName: "xterm" */ 'xterm');
const addons = await allHash({
fit: import(/* webpackChunkName: "xterm" */ 'xterm-addon-fit'),
webgl: import(/* webpackChunkName: "xterm" */ 'xterm-addon-webgl'),
weblinks: import(/* webpackChunkName: "xterm" */ 'xterm-addon-web-links'),
search: import(/* webpackChunkName: "xterm" */ 'xterm-addon-search'),
});
const terminal = new xterm.Terminal({
theme: {
background: docStyle.getPropertyValue('--terminal-bg').trim(),
foreground: docStyle.getPropertyValue('--terminal-text').trim(),
cursor: docStyle.getPropertyValue('--terminal-cursor').trim(),
selectionBackground: docStyle.getPropertyValue('--terminal-selection').trim(),
},
...this.xtermConfig,
});
this.fitAddon = new addons.fit.FitAddon();
this.searchAddon = new addons.search.SearchAddon();
try {
this.webglAddon = new addons.webgl.WebGlAddon();
} catch (e) {
// Some browsers (Safari) don't support the webgl renderer, so don't use it.
this.webglAddon = null;
}
terminal.loadAddon(this.fitAddon);
terminal.loadAddon(this.searchAddon);
terminal.loadAddon(new addons.weblinks.WebLinksAddon());
terminal.open(this.$refs.xterm);
if (this.webglAddon) {
terminal.loadAddon(this.webglAddon);
}
this.fit();
this.flush();
terminal.onData((input) => {
const msg = `0${ base64Encode(input) }`;
this.write(msg);
});
this.terminal = terminal;
},
write(msg) {
if (this.isOpen) {
this.socket.send(msg);
} else {
this.backlog.push(msg);
}
},
clear() {
this.terminal.clear();
},
getSocketUrl() {
if (!this.pod?.links?.view) {
return;
}
if (this.pod.os) {
this.os = this.pod.os;
this.backupShells = this.backupShells.filter((shell) => shell !== this.pod.os);
} else {
this.os = this.backupShells.shift();
}
const url = addParams(
`${ this.pod.links.view.replace(/^http/, 'ws') }/exec`,
{
container: this.container,
stdout: 1,
stdin: 1,
stderr: 1,
tty: 1,
command: commands[this.os],
}
);
return url;
},
async connect() {
if (this.socket) {
await this.socket.disconnect();
this.socket = null;
this.terminal.reset();
}
const url = this.getSocketUrl();
if (!url) {
return;
}
this.socket = new Socket(url, false, 0, 'base64.channel.k8s.io');
this.socket.addEventListener(EVENT_CONNECTING, (e) => {
this.isOpen = false;
this.isOpening = true;
this.errorMsg = '';
});
this.socket.addEventListener(EVENT_CONNECT_ERROR, (e) => {
this.isOpen = false;
this.isOpening = false;
console.error('Connect Error', e); // eslint-disable-line no-console
});
this.socket.addEventListener(EVENT_CONNECTED, (e) => {
this.isOpen = true;
this.isOpening = false;
this.fit();
this.flush();
if (this.commandOnFirstConnect) {
this.terminal.paste(`${ this.commandOnFirstConnect }`);
}
});
this.socket.addEventListener(EVENT_DISCONNECTED, (e) => {
this.isOpen = false;
this.isOpening = false;
// If we had an error message, try connecting with the next command
if (this.errorMsg) {
this.terminal.write(this.errorMsg);
if (this.backupShells.length && this.retries < 2) {
this.retries++;
// we're not really counting on this being a reactive change so there's no need to fire the whole action
this.pod.os = undefined;
// the pod will still return an os if one's been defined in the node so we'll skip the backups if that's the case and rely on retry count to break the retry loop
if (!this.pod.os) {
this.os = this.backupShells.shift();
}
this.connect();
} else {
// Output an message to let he user know none of the shell commands worked
this.terminal.write(this.t('wm.containerShell.failed'));
}
}
});
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
const type = e.detail.data.substr(0, 1);
const msg = base64Decode(e.detail.data.substr(1));
this.errorMsg = '';
if (`${ type }` === '1') {
if (msg) {
// we're not really counting on this being a reactive change so there's no need to fire the whole action
this.pod.os = this.os;
}
this.terminal.write(msg);
} else {
console.error(msg); // eslint-disable-line no-console
if (`${ type }` === '3') {
this.errorMsg = msg;
}
}
});
this.socket.connect();
this.terminal.focus();
},
flush() {
const backlog = this.backlog.slice();
this.backlog = [];
for (const data of backlog) {
this.socket.send(data);
}
},
fit(arg) {
if (!this.fitAddon) {
return;
}
this.fitAddon.fit();
const { rows, cols } = this.fitAddon.proposeDimensions();
if (!this.isOpen) {
return;
}
const message = `4${ base64Encode(
JSON.stringify({
Width: Math.floor(cols),
Height: Math.floor(rows),
})
) }`;
this.socket.send(message);
},
cleanup() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
if (this.terminal) {
this.terminal.dispose();
this.terminal = null;
}
},
},
};
</script>
<template>
<Window
:active="active"
:before-close="cleanup"
>
<template #title>
<Select
v-if="containerChoices.length > 0"
v-model="container"
:disabled="containerChoices.length === 1"
class="containerPicker auto-width pull-left"
:options="containerChoices"
:clearable="false"
placement="top"
>
<template #selected-option="option">
<t
v-if="option"
k="wm.containerShell.containerName"
:label="option.label"
/>
</template>
</Select>
<div class="pull-left ml-5">
<button
class="btn btn-sm bg-primary"
@click="clear"
>
<t k="wm.containerShell.clear" />
</button>
</div>
<div class="status pull-left">
<t
v-if="isOpen"
k="wm.connection.connected"
class="text-success"
/>
<t
v-else-if="isOpening"
k="wm.connection.connecting"
class="text-warning"
:raw="true"
/>
<t
v-else
k="wm.connection.disconnected"
class="text-error"
/>
</div>
</template>
<template #body>
<div
class="shell-container"
:class="{ open: isOpen, closed: !isOpen }"
>
<div
ref="xterm"
class="shell-body"
/>
<resize-observer @notify="fit" />
</div>
</template>
</Window>
</template>
<style lang="scss">
.xterm-char-measure-element {
position: static;
}
</style>
<style lang="scss" scoped>
.text-warning {
animation: flasher 2.5s linear infinite;
}
@keyframes flasher {
50% {
opacity: 0;
}
}
.shell-container {
height: 100%;
overflow: hidden;
.resize-observer {
display: none;
}
}
.shell-body {
padding: calc(2 * var(--outline-width));
height: 100%;
& > .terminal.focus {
outline: var(--outline-width) solid var(--outline);
}
}
.containerPicker {
::v-deep &.unlabeled-select {
display: inline-block;
min-width: 200px;
height: 30px;
min-height: 30px;
width: initial;
}
}
.status {
align-items: center;
display: flex;
min-width: 80px;
height: 30px;
margin-left: 10px;
}
</style>