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

457 lines
9.8 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 isEmpty from 'lodash/isEmpty';
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 DEFAULT_COMMAND = [
'/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',
];
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,
},
},
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,
};
},
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() {
await this.fetchNode();
await this.setupTerminal();
await this.connect();
clearInterval(this.keepAliveTimer);
this.keepAliveTimer = setInterval(() => {
this.fit();
}, 60 * 1000);
},
methods: {
async fetchNode() {
if (this.node) {
return;
}
const nodeId = this.pod.spec?.nodeName;
if ( !nodeId ) {
return;
}
try {
this.node = await this.$store.dispatch('cluster/find', {
type: NODE,
id: nodeId,
});
} catch (e) {
console.error('Failed to fetch node', nodeId); // eslint-disable-line no-console
}
},
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;
}
const { node } = this;
let cmd = DEFAULT_COMMAND;
if (!isEmpty(node) && node?.status?.nodeInfo?.operatingSystem === 'windows') {
cmd = ['cmd'];
}
const url = addParams(
`${ this.pod.links.view.replace(/^http/, 'ws') }/exec`,
{
container: this.container,
stdout: 1,
stdin: 1,
stderr: 1,
tty: 1,
command: cmd,
}
);
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.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();
});
this.socket.addEventListener(EVENT_DISCONNECTED, (e) => {
this.isOpen = false;
this.isOpening = false;
});
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
const type = e.detail.data.substr(0, 1);
const msg = base64Decode(e.detail.data.substr(1));
if (`${ type }` === '1') {
this.terminal.write(msg);
} else {
console.error(msg); // eslint-disable-line no-console
}
});
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>