feat(ui): create shared terminal component [BE-12697] (#1979)
This commit is contained in:
@@ -151,6 +151,7 @@ overrides:
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
'@vitest/no-conditional-expect': warn
|
||||
'max-classes-per-file': off
|
||||
- files:
|
||||
- app/**/*.stories.*
|
||||
rules:
|
||||
|
||||
@@ -287,11 +287,6 @@ input[type='checkbox'] {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'bootstrap/dist/css/bootstrap.css';
|
||||
import 'toastr/build/toastr.css';
|
||||
import 'xterm/dist/xterm.css';
|
||||
import 'angularjs-slider/dist/rzslider.css';
|
||||
import 'angular-loading-bar/build/loading-bar.css';
|
||||
import 'angular-moment-picker/dist/angular-moment-picker.min.css';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Terminal } from 'xterm';
|
||||
import * as fit from 'xterm/lib/addons/fit/fit';
|
||||
import { csrfInterceptor, csrfTokenReaderInterceptorAngular } from './portainer/services/csrf';
|
||||
import { agentInterceptor } from './portainer/services/axios';
|
||||
import { dispatchCacheRefreshEventIfNeeded } from './portainer/services/http-request.helper';
|
||||
@@ -33,8 +31,6 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService
|
||||
request: csrfInterceptor,
|
||||
}));
|
||||
|
||||
Terminal.applyAddon(fit);
|
||||
|
||||
$uibTooltipProvider.setTriggers({
|
||||
mouseenter: 'mouseleave',
|
||||
click: 'click',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import tokenize from '@nxmix/tokenize-ansi';
|
||||
import { FontWeight } from 'xterm';
|
||||
import { FontWeight } from '@xterm/xterm';
|
||||
|
||||
import {
|
||||
colors,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FontWeight } from 'xterm';
|
||||
import { FontWeight } from '@xterm/xterm';
|
||||
|
||||
import { type TextColor } from './colors';
|
||||
|
||||
|
||||
@@ -53,6 +53,6 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
<shell-terminal url="shellUrl" connect="shellConnect" on-state-change="(onShellStateChange)" on-resize="(onShellResize)"></shell-terminal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { Terminal } from 'xterm';
|
||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||
import { commandStringToArray } from '@/docker/helpers/containers';
|
||||
import { isLinuxTerminalCommand, LINUX_SHELL_INIT_COMMANDS } from '@@/Terminal/Terminal';
|
||||
|
||||
angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
'$transition$',
|
||||
'ContainerService',
|
||||
'ExecService',
|
||||
'ImageService',
|
||||
'Notifications',
|
||||
'ExecService',
|
||||
'HttpRequestHelper',
|
||||
'CONSOLE_COMMANDS_LABEL_PREFIX',
|
||||
'SidebarService',
|
||||
'endpoint',
|
||||
function ($scope, $state, $transition$, ContainerService, ImageService, Notifications, ExecService, HttpRequestHelper, CONSOLE_COMMANDS_LABEL_PREFIX, SidebarService, endpoint) {
|
||||
var socket, term;
|
||||
|
||||
let states = Object.freeze({
|
||||
function ($scope, $state, $transition$, ContainerService, ExecService, ImageService, Notifications, HttpRequestHelper, CONSOLE_COMMANDS_LABEL_PREFIX, endpoint) {
|
||||
const states = Object.freeze({
|
||||
disconnected: 0,
|
||||
connecting: 1,
|
||||
connected: 2,
|
||||
@@ -26,15 +23,29 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
$scope.loaded = false;
|
||||
$scope.states = states;
|
||||
$scope.state = states.disconnected;
|
||||
|
||||
$scope.formValues = {};
|
||||
$scope.containerCommands = [];
|
||||
|
||||
// Ensure the socket is closed before leaving the view
|
||||
$scope.shellUrl = '';
|
||||
$scope.shellConnect = false;
|
||||
$scope.onShellResize = null;
|
||||
$scope.shellInitCommands = null;
|
||||
|
||||
$scope.$on('$destroy', function () {
|
||||
$scope.disconnect();
|
||||
});
|
||||
|
||||
$scope.onShellStateChange = function (state) {
|
||||
$scope.$evalAsync(function () {
|
||||
if (state === 'connected') {
|
||||
$scope.state = states.connected;
|
||||
} else if (state === 'disconnected') {
|
||||
$scope.state = states.disconnected;
|
||||
$scope.shellConnect = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.connectAttach = function () {
|
||||
if ($scope.state > states.disconnected) {
|
||||
return;
|
||||
@@ -42,7 +53,7 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
|
||||
$scope.state = states.connecting;
|
||||
|
||||
let attachId = $transition$.params().id;
|
||||
const attachId = $transition$.params().id;
|
||||
|
||||
ContainerService.container(endpoint.Id, attachId)
|
||||
.then((details) => {
|
||||
@@ -52,22 +63,13 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
return;
|
||||
}
|
||||
|
||||
const params = {
|
||||
endpointId: $state.params.endpointId,
|
||||
id: attachId,
|
||||
$scope.onShellResize = function ({ rows, cols }) {
|
||||
ContainerService.resizeTTY(endpoint.Id, attachId, cols, rows);
|
||||
};
|
||||
|
||||
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
|
||||
var url =
|
||||
base +
|
||||
'api/websocket/attach?' +
|
||||
Object.keys(params)
|
||||
.map((k) => k + '=' + params[k])
|
||||
.join('&');
|
||||
|
||||
initTerm(url, ContainerService.resizeTTY.bind(this, endpoint.Id, attachId));
|
||||
$scope.shellUrl = buildShellUrl('api/websocket/attach', { endpointId: $state.params.endpointId, id: attachId });
|
||||
$scope.shellConnect = true;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
.catch(function (err) {
|
||||
Notifications.error('Error', err, 'Unable to retrieve container details');
|
||||
$scope.disconnect();
|
||||
});
|
||||
@@ -79,8 +81,9 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
}
|
||||
|
||||
$scope.state = states.connecting;
|
||||
var command = $scope.formValues.isCustomCommand ? $scope.formValues.customCommand : $scope.formValues.command;
|
||||
var execConfig = {
|
||||
|
||||
const command = $scope.formValues.isCustomCommand ? $scope.formValues.customCommand : $scope.formValues.command;
|
||||
const execConfig = {
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
@@ -90,171 +93,48 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
};
|
||||
|
||||
ContainerService.createExec(endpoint.Id, $transition$.params().id, execConfig)
|
||||
.then(function success(data) {
|
||||
const params = {
|
||||
endpointId: $state.params.endpointId,
|
||||
id: data.Id,
|
||||
.then(function (data) {
|
||||
$scope.onShellResize = function ({ rows, cols }) {
|
||||
ExecService.resizeTTY(data.Id, cols, rows);
|
||||
};
|
||||
|
||||
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
|
||||
var url =
|
||||
base +
|
||||
'api/websocket/exec?' +
|
||||
Object.keys(params)
|
||||
.map((k) => k + '=' + params[k])
|
||||
.join('&');
|
||||
|
||||
const isLinuxCommand = execConfig.Cmd ? isLinuxTerminalCommand(execConfig.Cmd[0]) : false;
|
||||
initTerm(url, ExecService.resizeTTY.bind(this, params.id), isLinuxCommand);
|
||||
if (isLinuxTerminalCommand(execConfig.Cmd[0])) {
|
||||
$scope.shellInitCommands = LINUX_SHELL_INIT_COMMANDS;
|
||||
}
|
||||
$scope.shellUrl = buildShellUrl('api/websocket/exec', { endpointId: $state.params.endpointId, id: data.Id });
|
||||
$scope.shellConnect = true;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
.catch(function (err) {
|
||||
Notifications.error('Failure', err, 'Unable to exec into container');
|
||||
$scope.disconnect();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.disconnect = function () {
|
||||
if (socket) {
|
||||
socket.close();
|
||||
}
|
||||
if ($scope.state > states.disconnected) {
|
||||
$scope.state = states.disconnected;
|
||||
if (term) {
|
||||
term.write('\n\r(connection closed)');
|
||||
term.dispose();
|
||||
}
|
||||
}
|
||||
$scope.shellConnect = false;
|
||||
$scope.state = states.disconnected;
|
||||
$scope.onShellResize = null;
|
||||
$scope.shellInitCommands = null;
|
||||
};
|
||||
|
||||
$scope.autoconnectAttachView = function () {
|
||||
return $scope.initView().then(function success() {
|
||||
return $scope.initView().then(function () {
|
||||
if ($scope.container.State.Running) {
|
||||
$scope.connectAttach();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function resize(restcall, add) {
|
||||
if ($scope.state != states.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
add = add || 0;
|
||||
|
||||
term.fit();
|
||||
var termWidth = term.cols;
|
||||
var termHeight = 30;
|
||||
term.resize(termWidth, termHeight);
|
||||
|
||||
restcall(termWidth + add, termHeight + add, 1);
|
||||
}
|
||||
|
||||
function isLinuxTerminalCommand(command) {
|
||||
const validShellCommands = ['ash', 'bash', 'dash', 'sh'];
|
||||
return validShellCommands.includes(command);
|
||||
}
|
||||
|
||||
function initTerm(url, resizeRestCall, isLinuxTerm = false) {
|
||||
let resizefun = resize.bind(this, resizeRestCall);
|
||||
|
||||
if ($transition$.params().nodeName) {
|
||||
url += '&nodeName=' + $transition$.params().nodeName;
|
||||
}
|
||||
|
||||
if (url.indexOf('https') > -1) {
|
||||
url = url.replace('https://', 'wss://');
|
||||
} else {
|
||||
url = url.replace('http://', 'ws://');
|
||||
}
|
||||
|
||||
socket = new WebSocket(url);
|
||||
|
||||
socket.onopen = function () {
|
||||
let closeTerminal = false;
|
||||
let commandBuffer = '';
|
||||
|
||||
$scope.state = states.connected;
|
||||
term = new Terminal();
|
||||
|
||||
if (isLinuxTerm) {
|
||||
// linux terminals support xterm
|
||||
socket.send('export LANG=C.UTF-8\n');
|
||||
socket.send('export LC_ALL=C.UTF-8\n');
|
||||
socket.send('export TERM="xterm-256color"\n');
|
||||
socket.send('alias ls="ls --color=auto"\n');
|
||||
socket.send('echo -e "\\033[2J\\033[H"\n');
|
||||
}
|
||||
|
||||
term.onData(function (data) {
|
||||
socket.send(data);
|
||||
|
||||
// This code is detect whether the user has
|
||||
// typed CTRL+D or exit in the terminal
|
||||
if (data === '\x04') {
|
||||
// If the user types CTRL+D, close the terminal
|
||||
closeTerminal = true;
|
||||
} else if (data === '\r') {
|
||||
if (commandBuffer.trim() === 'exit') {
|
||||
closeTerminal = true;
|
||||
}
|
||||
commandBuffer = '';
|
||||
} else {
|
||||
commandBuffer += data;
|
||||
}
|
||||
});
|
||||
|
||||
var terminal_container = document.getElementById('terminal-container');
|
||||
term.open(terminal_container);
|
||||
term.focus();
|
||||
term.setOption('cursorBlink', true);
|
||||
|
||||
window.onresize = function () {
|
||||
resizefun();
|
||||
$scope.$apply();
|
||||
};
|
||||
|
||||
$scope.$watch(SidebarService.isSidebarOpen, function () {
|
||||
setTimeout(resizefun, 400);
|
||||
});
|
||||
|
||||
socket.onmessage = function (e) {
|
||||
term.write(e.data);
|
||||
};
|
||||
|
||||
socket.onerror = function (err) {
|
||||
if (closeTerminal) {
|
||||
$scope.disconnect();
|
||||
} else {
|
||||
Notifications.error('Failure', err, 'Connection error');
|
||||
}
|
||||
$scope.$apply();
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
if (closeTerminal) {
|
||||
$scope.disconnect();
|
||||
}
|
||||
$scope.$apply();
|
||||
};
|
||||
|
||||
resizefun(1);
|
||||
$scope.$apply();
|
||||
};
|
||||
}
|
||||
|
||||
$scope.initView = function () {
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
|
||||
return ContainerService.container(endpoint.Id, $transition$.params().id)
|
||||
.then(function success(data) {
|
||||
var container = data;
|
||||
$scope.container = container;
|
||||
return ImageService.image(container.Image);
|
||||
.then(function (data) {
|
||||
$scope.container = data;
|
||||
return ImageService.image(data.Image);
|
||||
})
|
||||
.then(function success(data) {
|
||||
var image = data;
|
||||
var containerLabels = $scope.container.Config.Labels;
|
||||
$scope.imageOS = image.Os;
|
||||
$scope.formValues.command = image.Os === 'windows' ? 'powershell' : 'bash';
|
||||
.then(function (data) {
|
||||
const containerLabels = $scope.container.Config.Labels;
|
||||
$scope.imageOS = data.Os;
|
||||
$scope.formValues.command = data.Os === 'windows' ? 'powershell' : 'bash';
|
||||
$scope.containerCommands = Object.keys(containerLabels)
|
||||
.filter(function (label) {
|
||||
return label.indexOf(CONSOLE_COMMANDS_LABEL_PREFIX) === 0;
|
||||
@@ -267,7 +147,7 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
});
|
||||
$scope.loaded = true;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
.catch(function (err) {
|
||||
Notifications.error('Error', err, 'Unable to retrieve container details');
|
||||
});
|
||||
};
|
||||
@@ -277,5 +157,20 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
$scope.formValues.isCustomCommand = enabled;
|
||||
});
|
||||
};
|
||||
|
||||
function buildShellUrl(path, params) {
|
||||
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
|
||||
let url =
|
||||
base +
|
||||
path +
|
||||
'?' +
|
||||
Object.keys(params)
|
||||
.map((k) => k + '=' + params[k])
|
||||
.join('&');
|
||||
if ($transition$.params().nodeName) {
|
||||
url += '&nodeName=' + $transition$.params().nodeName;
|
||||
}
|
||||
return url.startsWith('https') ? url.replace('https://', 'wss://') : url.replace('http://', 'ws://');
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -96,6 +96,6 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
<shell-terminal url="shellUrl" connect="shellConnect" on-state-change="(onShellStateChange)" on-resize="(onShellResize)" initial-commands="shellInitCommands"></shell-terminal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { FallbackImage } from '@@/FallbackImage';
|
||||
import { BadgeIcon } from '@@/BadgeIcon';
|
||||
import { TeamsSelector } from '@@/TeamsSelector';
|
||||
import { TerminalTooltip } from '@@/TerminalTooltip';
|
||||
import { Terminal } from '@@/Terminal/Terminal';
|
||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
import { Slider } from '@@/form-components/Slider';
|
||||
import { TagButton } from '@@/TagButton';
|
||||
@@ -269,7 +270,17 @@ export const ngModule = angular
|
||||
'inlineLoader',
|
||||
r2a(InlineLoader, ['children', 'className', 'size'])
|
||||
)
|
||||
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []));
|
||||
.component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, []))
|
||||
.component(
|
||||
'shellTerminal',
|
||||
r2a(Terminal, [
|
||||
'url',
|
||||
'connect',
|
||||
'onStateChange',
|
||||
'onResize',
|
||||
'initialCommands',
|
||||
])
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function notifyWarning(title: string, text: string) {
|
||||
toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 });
|
||||
}
|
||||
|
||||
export function notifyError(title: string, e?: Error, fallbackText = '') {
|
||||
export function notifyError(title: string, e?: unknown, fallbackText = '') {
|
||||
const msg = pickErrorMsg(e) || fallbackText;
|
||||
saveNotification(title, msg, 'error');
|
||||
|
||||
@@ -69,7 +69,7 @@ export function Notifications() {
|
||||
};
|
||||
}
|
||||
|
||||
function pickErrorMsg(e?: Error) {
|
||||
function pickErrorMsg(e?: unknown) {
|
||||
if (!e) {
|
||||
return '';
|
||||
}
|
||||
|
||||
186
app/react/components/Terminal/Terminal.stories.tsx
Normal file
186
app/react/components/Terminal/Terminal.stories.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Meta } from '@storybook/react';
|
||||
import { ws } from 'msw';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { Terminal } from './Terminal';
|
||||
import type { ShellState } from './Terminal';
|
||||
|
||||
// Computed at module load so the handler URL matches whatever port Storybook runs on
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const SHELL_WS_URL = `${wsProtocol}${window.location.host}/api/websocket/test-shell`;
|
||||
|
||||
const shellHandler = ws.link(SHELL_WS_URL);
|
||||
|
||||
const PROMPT = '\r\n\x1b[32muser@portainer\x1b[0m:\x1b[34m~\x1b[0m$ ';
|
||||
|
||||
const COMMANDS: Record<string, (args: string[]) => string> = {
|
||||
help: () =>
|
||||
'Available commands: clear, date, echo, exit, help, ls, pwd, whoami',
|
||||
echo: (args) => args.join(' '),
|
||||
ls: () =>
|
||||
'\x1b[34mbin\x1b[0m \x1b[34metc\x1b[0m \x1b[34mhome\x1b[0m \x1b[32mapp\x1b[0m \x1b[32mserver\x1b[0m README.md',
|
||||
pwd: () => '/home/user',
|
||||
whoami: () => 'user',
|
||||
date: () => new Date().toString(),
|
||||
};
|
||||
|
||||
function createBashHandler() {
|
||||
return shellHandler.addEventListener('connection', ({ client }) => {
|
||||
let buffer = '';
|
||||
|
||||
client.send(
|
||||
'Portainer MSW shell — type \x1b[1mhelp\x1b[0m for available commands'
|
||||
);
|
||||
client.send(PROMPT);
|
||||
|
||||
client.addEventListener('message', ({ data }) => {
|
||||
const str = String(data);
|
||||
|
||||
// Escape sequences (arrow keys etc.) — ignore
|
||||
if (str.startsWith('\x1b')) {
|
||||
return;
|
||||
}
|
||||
|
||||
str.split('').forEach((char) => {
|
||||
if (char === '\r') {
|
||||
onEnter();
|
||||
} else if (char === '\x7f' || char === '\b') {
|
||||
onBackspace();
|
||||
} else if (char === '\x03') {
|
||||
onCtrlC();
|
||||
} else if (char === '\x04') {
|
||||
onCtrlD();
|
||||
} else if (char === '\x0c') {
|
||||
onCtrlL();
|
||||
} else if (char.charCodeAt(0) >= 32) {
|
||||
buffer += char;
|
||||
client.send(char);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function onEnter() {
|
||||
const cmd = buffer.trim();
|
||||
buffer = '';
|
||||
client.send('\r\n');
|
||||
|
||||
if (!cmd) {
|
||||
client.send(PROMPT);
|
||||
return;
|
||||
}
|
||||
|
||||
const [command, ...args] = cmd.split(/\s+/);
|
||||
|
||||
if (command === 'exit') {
|
||||
client.send('logout\r\n');
|
||||
setTimeout(() => client.close(), 200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'clear') {
|
||||
client.send('\x1b[2J\x1b[H');
|
||||
client.send(PROMPT);
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = COMMANDS[command];
|
||||
if (handler) {
|
||||
client.send(handler(args));
|
||||
} else {
|
||||
client.send(`\x1b[31mbash: ${command}: command not found\x1b[0m`);
|
||||
}
|
||||
|
||||
client.send(PROMPT);
|
||||
}
|
||||
|
||||
function onBackspace() {
|
||||
if (buffer.length === 0) return;
|
||||
buffer = buffer.slice(0, -1);
|
||||
client.send('\b \b');
|
||||
}
|
||||
|
||||
function onCtrlC() {
|
||||
buffer = '';
|
||||
client.send('^C');
|
||||
client.send(PROMPT);
|
||||
}
|
||||
|
||||
function onCtrlD() {
|
||||
client.send('\r\nlogout\r\n');
|
||||
client.close();
|
||||
}
|
||||
|
||||
function onCtrlL() {
|
||||
buffer = '';
|
||||
client.send('\x1b[2J\x1b[H');
|
||||
client.send(PROMPT);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/Terminal',
|
||||
component: Terminal,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
export function AutoConnect() {
|
||||
return (
|
||||
<div className="h-[400px]">
|
||||
<Terminal url={SHELL_WS_URL} connect />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
AutoConnect.parameters = {
|
||||
msw: { handlers: [createBashHandler()] },
|
||||
};
|
||||
|
||||
export function WithConnectButton() {
|
||||
const [connect, setConnect] = useState(false);
|
||||
const [state, setState] = useState<ShellState>('idle');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => setConnect((c) => !c)}
|
||||
disabled={state === 'connecting'}
|
||||
data-cy="connect button"
|
||||
>
|
||||
{state === 'connected' ? 'Disconnect' : 'Connect'}
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">state: {state}</span>
|
||||
</div>
|
||||
<div className="h-[400px]">
|
||||
<Terminal
|
||||
url={SHELL_WS_URL}
|
||||
connect={connect}
|
||||
onStateChange={setState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
WithConnectButton.parameters = {
|
||||
msw: { handlers: [createBashHandler()] },
|
||||
};
|
||||
|
||||
export function ServerDisconnects() {
|
||||
return (
|
||||
<div className="h-[400px]">
|
||||
<Terminal url={SHELL_WS_URL} connect />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ServerDisconnects.parameters = {
|
||||
msw: {
|
||||
handlers: [
|
||||
shellHandler.addEventListener('connection', ({ client }) => {
|
||||
client.send('# Closing in 2s...\r\n');
|
||||
setTimeout(() => client.close(), 2000);
|
||||
}),
|
||||
],
|
||||
},
|
||||
};
|
||||
340
app/react/components/Terminal/Terminal.test.tsx
Normal file
340
app/react/components/Terminal/Terminal.test.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
import { server, ws } from '@/setup-tests/server';
|
||||
|
||||
import { Terminal } from './Terminal';
|
||||
|
||||
type WSConnection = Parameters<
|
||||
Parameters<ReturnType<typeof ws.link>['addEventListener']>[1]
|
||||
>[0];
|
||||
type ClientConnection = WSConnection['client'];
|
||||
|
||||
const wssLink = ws.link('wss://*/*');
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: vi.fn(
|
||||
class {
|
||||
open = vi.fn();
|
||||
|
||||
options = {};
|
||||
|
||||
focus = vi.fn();
|
||||
|
||||
loadAddon = vi.fn();
|
||||
|
||||
write = vi.fn();
|
||||
|
||||
onData = vi.fn();
|
||||
|
||||
onKey = vi.fn();
|
||||
|
||||
dispose = vi.fn();
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@xterm/addon-fit', () => ({
|
||||
FitAddon: vi.fn(
|
||||
class {
|
||||
fit = vi.fn();
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/portainer/services/notifications', () => ({
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
let mockTerminalInstance: Partial<XTerm>;
|
||||
let mockFitAddonInstance: Partial<FitAddon>;
|
||||
let mockResizeObserverObserve: ReturnType<typeof vi.fn>;
|
||||
let mockResizeObserverDisconnect: ReturnType<typeof vi.fn>;
|
||||
let mockResizeObserverCallback: ResizeObserverCallback;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockFitAddonInstance = { fit: vi.fn() };
|
||||
|
||||
mockTerminalInstance = {
|
||||
open: vi.fn(),
|
||||
options: {},
|
||||
focus: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
write: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
};
|
||||
|
||||
vi.mocked(XTerm).mockImplementation(function XTerm(this: XTerm) {
|
||||
Object.assign(this, mockTerminalInstance);
|
||||
});
|
||||
|
||||
vi.mocked(FitAddon).mockImplementation(function FitAddon(this: FitAddon) {
|
||||
Object.assign(this, mockFitAddonInstance);
|
||||
});
|
||||
|
||||
mockResizeObserverObserve = vi.fn();
|
||||
mockResizeObserverDisconnect = vi.fn();
|
||||
globalThis.ResizeObserver = vi
|
||||
.fn()
|
||||
// eslint-disable-next-line prefer-arrow-callback
|
||||
.mockImplementation(function ResizeObserver(
|
||||
this: ResizeObserver,
|
||||
callback: ResizeObserverCallback
|
||||
) {
|
||||
mockResizeObserverCallback = callback;
|
||||
return {
|
||||
observe: mockResizeObserverObserve,
|
||||
disconnect: mockResizeObserverDisconnect,
|
||||
};
|
||||
});
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client, server }) => {
|
||||
client.addEventListener('message', (event) => {
|
||||
server.send(event.data);
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const TEST_URL = 'wss://localhost:3000/api/test';
|
||||
|
||||
describe('Terminal', () => {
|
||||
describe('connection lifecycle', () => {
|
||||
it('does not create terminal when connect=false', () => {
|
||||
render(<Terminal url={TEST_URL} connect={false} />);
|
||||
expect(vi.mocked(XTerm)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onStateChange with connecting immediately when connect=true', () => {
|
||||
const onStateChange = vi.fn();
|
||||
render(<Terminal url={TEST_URL} connect onStateChange={onStateChange} />);
|
||||
expect(onStateChange).toHaveBeenCalledWith('connecting');
|
||||
});
|
||||
|
||||
it('calls onStateChange with connected when socket opens', async () => {
|
||||
const onStateChange = vi.fn();
|
||||
|
||||
render(<Terminal url={TEST_URL} connect onStateChange={onStateChange} />);
|
||||
|
||||
// Wait for the terminal to open (same event that triggers 'connected')
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
expect(onStateChange).toHaveBeenCalledWith('connected');
|
||||
});
|
||||
|
||||
it('calls onStateChange with disconnected when socket closes', async () => {
|
||||
const onStateChange = vi.fn();
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
render(<Terminal url={TEST_URL} connect onStateChange={onStateChange} />);
|
||||
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
clientConnection!.close();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStateChange).toHaveBeenCalledWith('disconnected');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal initialization', () => {
|
||||
it('opens on socket open', async () => {
|
||||
render(<Terminal url={TEST_URL} connect />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('registers resize observer on container element', async () => {
|
||||
render(<Terminal url={TEST_URL} connect />);
|
||||
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
|
||||
expect(mockResizeObserverObserve).toHaveBeenCalledWith(
|
||||
expect.any(HTMLElement)
|
||||
);
|
||||
});
|
||||
|
||||
it('fits terminal when container resizes', async () => {
|
||||
render(<Terminal url={TEST_URL} connect />);
|
||||
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
|
||||
mockResizeObserverCallback([], {} as ResizeObserver);
|
||||
|
||||
expect(mockFitAddonInstance.fit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('data flow', () => {
|
||||
it('forwards terminal input to WebSocket', async () => {
|
||||
let receivedData: string | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
client.addEventListener('message', (event) => {
|
||||
receivedData = event.data as string;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<Terminal url={TEST_URL} connect />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockTerminalInstance.onData).toHaveBeenCalled()
|
||||
);
|
||||
|
||||
const onDataCallback = vi.mocked(mockTerminalInstance.onData!).mock
|
||||
.calls[0]![0] as (data: string) => void;
|
||||
|
||||
onDataCallback('kubectl get pods');
|
||||
|
||||
await waitFor(() => expect(receivedData).toBe('kubectl get pods'));
|
||||
});
|
||||
|
||||
it('writes WebSocket messages to terminal as Uint8Array', async () => {
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
client.send('server output');
|
||||
})
|
||||
);
|
||||
|
||||
render(<Terminal url={TEST_URL} connect />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockTerminalInstance.write).toHaveBeenCalled()
|
||||
);
|
||||
|
||||
const [data] = vi.mocked(mockTerminalInstance.write!).mock.calls[0];
|
||||
expect(new TextDecoder().decode(data as Uint8Array)).toBe(
|
||||
'server output'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('disposes terminal and disconnects resize observer when socket closes', async () => {
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
render(<Terminal url={TEST_URL} connect />);
|
||||
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
|
||||
clientConnection!.close();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockTerminalInstance.dispose).toHaveBeenCalled()
|
||||
);
|
||||
expect(mockResizeObserverDisconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disposes terminal and disconnects resize observer on unmount', async () => {
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
const { unmount } = render(<Terminal url={TEST_URL} connect />);
|
||||
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||||
expect(mockResizeObserverDisconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onResize', () => {
|
||||
it('calls onResize with terminal dimensions after initial connection', async () => {
|
||||
const onResize = vi.fn();
|
||||
|
||||
render(<Terminal url={TEST_URL} connect onResize={onResize} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onResize).toHaveBeenCalledWith({ rows: 24, cols: 80 })
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onResize with terminal dimensions when connect changes to true', async () => {
|
||||
const onResize = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<Terminal url={TEST_URL} connect={false} onResize={onResize} />
|
||||
);
|
||||
expect(onResize).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<Terminal url={TEST_URL} connect onResize={onResize} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onResize).toHaveBeenCalledWith({ rows: 24, cols: 80 })
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onResize with terminal dimensions when container resizes', async () => {
|
||||
const onResize = vi.fn();
|
||||
|
||||
render(<Terminal url={TEST_URL} connect onResize={onResize} />);
|
||||
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
onResize.mockClear();
|
||||
|
||||
mockResizeObserverCallback([], {} as ResizeObserver);
|
||||
|
||||
expect(onResize).toHaveBeenCalledWith({ rows: 24, cols: 80 });
|
||||
});
|
||||
|
||||
it('does not call onResize when connect=false', () => {
|
||||
const onResize = vi.fn();
|
||||
|
||||
render(<Terminal url={TEST_URL} connect={false} onResize={onResize} />);
|
||||
|
||||
expect(onResize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('does not show error notification when socket closes cleanly', async () => {
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
client.close();
|
||||
})
|
||||
);
|
||||
|
||||
const onStateChange = vi.fn();
|
||||
render(<Terminal url={TEST_URL} connect onStateChange={onStateChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStateChange).toHaveBeenCalledWith('disconnected');
|
||||
});
|
||||
expect(vi.mocked(notifyError)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
153
app/react/components/Terminal/Terminal.tsx
Normal file
153
app/react/components/Terminal/Terminal.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
export type ShellState = 'idle' | 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
export interface TerminalDimensions {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export const LINUX_SHELL_INIT_COMMANDS = [
|
||||
'export LANG=C.UTF-8\n',
|
||||
'export LC_ALL=C.UTF-8\n',
|
||||
'export TERM="xterm-256color"\n',
|
||||
'alias ls="ls --color=auto"\n',
|
||||
'clear\n',
|
||||
];
|
||||
|
||||
export function isLinuxTerminalCommand(command: string): boolean {
|
||||
const LINUX_SHELLS = [
|
||||
'bash',
|
||||
'sh',
|
||||
'zsh',
|
||||
'ash',
|
||||
'dash',
|
||||
'fish',
|
||||
'csh',
|
||||
'ksh',
|
||||
];
|
||||
const basename = command.split('/').pop() ?? command;
|
||||
return LINUX_SHELLS.includes(basename);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
connect: boolean;
|
||||
onStateChange?: (state: ShellState) => void;
|
||||
onResize?: ((dimensions: TerminalDimensions) => void) | null;
|
||||
initialCommands?: string[];
|
||||
}
|
||||
|
||||
export function Terminal({
|
||||
url,
|
||||
connect,
|
||||
onStateChange = () => {},
|
||||
onResize = () => {},
|
||||
initialCommands,
|
||||
}: Props) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connect) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let fitAddon: FitAddon | null = null;
|
||||
let cleaned = false;
|
||||
|
||||
onStateChange('connecting');
|
||||
|
||||
const socket = new WebSocket(url);
|
||||
socketRef.current = socket;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleResize();
|
||||
});
|
||||
|
||||
socket.addEventListener('open', onOpen);
|
||||
socket.addEventListener('message', onMessage);
|
||||
socket.addEventListener('close', onClose);
|
||||
socket.addEventListener('error', onError);
|
||||
|
||||
return cleanup;
|
||||
|
||||
function onOpen() {
|
||||
if (!terminalRef.current) {
|
||||
return;
|
||||
}
|
||||
const term = new XTerm();
|
||||
termRef.current = term;
|
||||
fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(terminalRef.current);
|
||||
term.options.cursorBlink = true;
|
||||
term.focus();
|
||||
setTimeout(() => {
|
||||
handleResize();
|
||||
}, 0);
|
||||
term.onData((data) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data);
|
||||
}
|
||||
});
|
||||
term.onKey(({ domEvent }) => {
|
||||
if (domEvent.ctrlKey && domEvent.key === 'd') {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
initialCommands?.forEach((cmd) => socket.send(cmd));
|
||||
onStateChange('connected');
|
||||
}
|
||||
|
||||
function onMessage(e: MessageEvent) {
|
||||
const encoded = new TextEncoder().encode(e.data);
|
||||
termRef.current?.write(encoded);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function onError(e: Event) {
|
||||
if (socket.readyState !== WebSocket.CLOSED) {
|
||||
notifyError('Failure', e, 'Websocket connection error');
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
socket.removeEventListener('open', onOpen);
|
||||
socket.removeEventListener('message', onMessage);
|
||||
socket.removeEventListener('close', onClose);
|
||||
socket.removeEventListener('error', onError);
|
||||
resizeObserver.disconnect();
|
||||
socket.close();
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
socketRef.current = null;
|
||||
fitAddon = null;
|
||||
onStateChange('disconnected');
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
fitAddon?.fit();
|
||||
if (termRef.current) {
|
||||
onResize?.({ rows: termRef.current.rows, cols: termRef.current.cols });
|
||||
}
|
||||
}
|
||||
// onStateChange, onResize, and initialCommands intentionally excluded — callers pass stable refs
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [connect, url]);
|
||||
|
||||
return <div ref={terminalRef} className="h-full" />;
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { Terminal as TerminalIcon } from 'lucide-react';
|
||||
import { Terminal } from 'xterm';
|
||||
|
||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import { TerminalTooltip } from '@/react/components/TerminalTooltip';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
@@ -12,10 +10,12 @@ import { Widget, WidgetBody } from '@@/Widget';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Button } from '@@/buttons';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
||||
interface StringDictionary {
|
||||
[index: string]: string;
|
||||
}
|
||||
import {
|
||||
Terminal,
|
||||
isLinuxTerminalCommand,
|
||||
LINUX_SHELL_INIT_COMMANDS,
|
||||
} from '@@/Terminal/Terminal';
|
||||
import type { ShellState } from '@@/Terminal/Terminal';
|
||||
|
||||
export function ConsoleView() {
|
||||
const {
|
||||
@@ -29,24 +29,17 @@ export function ConsoleView() {
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const [command, setCommand] = useState('/bin/sh');
|
||||
const [connectionStatus, setConnectionStatus] = useState('closed');
|
||||
const [terminal, setTerminal] = useState(null as Terminal | null);
|
||||
const [socket, setSocket] = useState(null as WebSocket | null);
|
||||
const [connect, setConnect] = useState(false);
|
||||
const [shellState, setShellState] = useState<ShellState>('idle');
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
label: 'Namespaces',
|
||||
link: 'kubernetes.resourcePools',
|
||||
},
|
||||
{ label: 'Namespaces', link: 'kubernetes.resourcePools' },
|
||||
{
|
||||
label: namespace,
|
||||
link: 'kubernetes.resourcePools.resourcePool',
|
||||
linkParams: { id: namespace },
|
||||
},
|
||||
{
|
||||
label: 'Applications',
|
||||
link: 'kubernetes.applications',
|
||||
},
|
||||
{ label: 'Applications', link: 'kubernetes.applications' },
|
||||
{
|
||||
label: appName,
|
||||
link: 'kubernetes.applications.application',
|
||||
@@ -59,51 +52,6 @@ export function ConsoleView() {
|
||||
'Console',
|
||||
];
|
||||
|
||||
const disconnectConsole = useCallback(() => {
|
||||
socket?.close();
|
||||
terminal?.dispose();
|
||||
setTerminal(null);
|
||||
setSocket(null);
|
||||
setConnectionStatus('closed');
|
||||
}, [socket, terminal, setConnectionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.onopen = () => {
|
||||
const terminalContainer = document.getElementById('terminal-container');
|
||||
if (terminalContainer) {
|
||||
terminal?.open(terminalContainer);
|
||||
terminal?.setOption('cursorBlink', true);
|
||||
terminal?.focus();
|
||||
setConnectionStatus('open');
|
||||
socket.send('export LANG=C.UTF-8\n');
|
||||
socket.send('export LC_ALL=C.UTF-8\n');
|
||||
socket.send('clear\n');
|
||||
}
|
||||
};
|
||||
|
||||
socket.onmessage = (msg) => {
|
||||
const encoded = new TextEncoder().encode(msg.data);
|
||||
terminal?.writeUtf8(encoded);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
disconnectConsole();
|
||||
notifyError('Websocket connection error');
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
disconnectConsole();
|
||||
};
|
||||
}
|
||||
}, [disconnectConsole, setConnectionStatus, socket, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
terminal?.onData((data) => {
|
||||
socket?.send(data);
|
||||
});
|
||||
}, [terminal, socket]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -137,9 +85,7 @@ export function ConsoleView() {
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
id="consoleCommand"
|
||||
// disable eslint because we want to autofocus
|
||||
// this is ok because we only have one input on the page
|
||||
// https://portainer.atlassian.net/browse/EE-5752
|
||||
disabled={connect}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
data-cy="console-command-input"
|
||||
@@ -150,17 +96,13 @@ export function ConsoleView() {
|
||||
<Button
|
||||
className="btn btn-primary !ml-0"
|
||||
data-cy="connect-console-button"
|
||||
onClick={
|
||||
connectionStatus === 'closed'
|
||||
? connectConsole
|
||||
: disconnectConsole
|
||||
}
|
||||
disabled={connectionStatus === 'connecting'}
|
||||
onClick={connect ? handleDisconnect : handleConnect}
|
||||
disabled={shellState === 'connecting'}
|
||||
>
|
||||
{connectionStatus === 'open' && 'Disconnect'}
|
||||
{connectionStatus === 'connecting' && 'Connecting'}
|
||||
{connectionStatus !== 'connecting' &&
|
||||
connectionStatus !== 'open' &&
|
||||
{shellState === 'connected' && 'Disconnect'}
|
||||
{shellState === 'connecting' && 'Connecting'}
|
||||
{shellState !== 'connecting' &&
|
||||
shellState !== 'connected' &&
|
||||
'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -168,7 +110,16 @@ export function ConsoleView() {
|
||||
</Widget>
|
||||
<div className="row">
|
||||
<div className="col-sm-12 p-0">
|
||||
<div id="terminal-container" className="terminal-container" />
|
||||
<Terminal
|
||||
url={buildUrl()}
|
||||
connect={connect}
|
||||
onStateChange={handleStateChange}
|
||||
initialCommands={
|
||||
isLinuxTerminalCommand(command)
|
||||
? LINUX_SHELL_INIT_COMMANDS
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,8 +127,23 @@ export function ConsoleView() {
|
||||
</>
|
||||
);
|
||||
|
||||
function connectConsole() {
|
||||
const params: StringDictionary = {
|
||||
function handleConnect() {
|
||||
setConnect(true);
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
setConnect(false);
|
||||
}
|
||||
|
||||
function handleStateChange(state: ShellState) {
|
||||
if (state === 'disconnected') {
|
||||
setConnect(false);
|
||||
}
|
||||
setShellState(state);
|
||||
}
|
||||
|
||||
function buildUrl() {
|
||||
const params: Record<string, string> = {
|
||||
endpointId: environmentId,
|
||||
namespace,
|
||||
podName: podID,
|
||||
@@ -197,11 +163,6 @@ export function ConsoleView() {
|
||||
} else {
|
||||
url = url.replace('http://', 'ws://');
|
||||
}
|
||||
|
||||
setConnectionStatus('connecting');
|
||||
const term = new Terminal();
|
||||
setTerminal(term);
|
||||
const socket = new WebSocket(url);
|
||||
setSocket(socket);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,17 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import { Terminal } from 'xterm';
|
||||
import { fit } from 'xterm/lib/addons/fit/fit';
|
||||
|
||||
import { terminalClose } from '@/portainer/services/terminal-window';
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
import { server, ws } from '@/setup-tests/server';
|
||||
|
||||
import { Terminal } from '@@/Terminal/Terminal';
|
||||
import type { ShellState } from '@@/Terminal/Terminal';
|
||||
|
||||
import { KubectlShellView } from './KubectlShellView';
|
||||
|
||||
// Type helpers for MSW WebSocket connections
|
||||
type WSConnection = Parameters<
|
||||
Parameters<ReturnType<typeof ws.link>['addEventListener']>[1]
|
||||
>[0];
|
||||
type ClientConnection = WSConnection['client'];
|
||||
type ServerConnection = WSConnection['server'];
|
||||
|
||||
// Shared WebSocket links for all tests
|
||||
const wssLink = ws.link('wss://*/*');
|
||||
const wsLink = ws.link('ws://*/*');
|
||||
|
||||
// Mock modules
|
||||
vi.mock('xterm', () => ({
|
||||
Terminal: vi.fn(
|
||||
class {
|
||||
open = vi.fn();
|
||||
|
||||
setOption = vi.fn();
|
||||
|
||||
focus = vi.fn();
|
||||
|
||||
writeln = vi.fn();
|
||||
|
||||
writeUtf8 = vi.fn();
|
||||
|
||||
onData = vi.fn();
|
||||
|
||||
onKey = vi.fn();
|
||||
|
||||
dispose = vi.fn();
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('xterm/lib/addons/fit/fit', () => ({
|
||||
fit: vi.fn(),
|
||||
vi.mock('@@/Terminal/Terminal', () => ({
|
||||
Terminal: vi.fn(() => null),
|
||||
LINUX_SHELL_INIT_COMMANDS: [],
|
||||
}));
|
||||
|
||||
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||
@@ -60,485 +26,120 @@ vi.mock('@/portainer/services/terminal-window', () => ({
|
||||
terminalClose: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/portainer/services/notifications', () => ({
|
||||
error: vi.fn(),
|
||||
}));
|
||||
function getTerminalProps() {
|
||||
return vi.mocked(Terminal).mock.calls[0][0] as {
|
||||
url: string;
|
||||
connect: boolean;
|
||||
onStateChange?: (state: ShellState) => void;
|
||||
};
|
||||
}
|
||||
|
||||
let mockTerminalInstance: Partial<Terminal>;
|
||||
function triggerStateChange(state: ShellState) {
|
||||
act(() => {
|
||||
getTerminalProps().onStateChange?.(state);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockTerminalInstance = {
|
||||
open: vi.fn(),
|
||||
setOption: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
writeUtf8: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(Terminal).mockImplementation(function Terminal(this: Terminal) {
|
||||
Object.assign(this, mockTerminalInstance);
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'https:', host: 'localhost:3000' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'addEventListener', {
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'removeEventListener', {
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Set up default echo handler for tests that don't need custom behavior
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client, server }) => {
|
||||
client.addEventListener('message', (event) => {
|
||||
server.send(event.data);
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('KubectlShellView', () => {
|
||||
it('renders loading state initially', () => {
|
||||
render(<KubectlShellView />);
|
||||
expect(screen.getByText('Loading Terminal...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates WebSocket connection with correct URL', async () => {
|
||||
let connectionUrl: string | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
connectionUrl = client.url.toString();
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
|
||||
await waitFor(() => expect(connectionUrl).toBeDefined());
|
||||
expect(connectionUrl).toBe(
|
||||
'wss://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('creates WebSocket connection with ws protocol when location is http', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http:', host: 'localhost:3000' },
|
||||
writable: true,
|
||||
describe('URL construction', () => {
|
||||
it('builds wss:// URL when location is https', () => {
|
||||
render(<KubectlShellView />);
|
||||
expect(getTerminalProps().url).toBe(
|
||||
'wss://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1'
|
||||
);
|
||||
});
|
||||
|
||||
let connectionUrl: string | undefined;
|
||||
|
||||
server.use(
|
||||
wsLink.addEventListener('connection', ({ client }) => {
|
||||
connectionUrl = client.url.toString();
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
|
||||
await waitFor(() => expect(connectionUrl).toBeDefined());
|
||||
expect(connectionUrl).toBe(
|
||||
'ws://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets up terminal event handlers on mount', () => {
|
||||
render(<KubectlShellView />);
|
||||
expect(mockTerminalInstance.onData).toHaveBeenCalled();
|
||||
expect(mockTerminalInstance.onKey).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds window resize listener on mount', () => {
|
||||
render(<KubectlShellView />);
|
||||
expect(window.addEventListener).toHaveBeenCalledWith(
|
||||
'resize',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('sends terminal data to WebSocket when terminal data event fires', async () => {
|
||||
let receivedData: string | undefined;
|
||||
let connectionEstablished = false;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
connectionEstablished = true;
|
||||
client.addEventListener('message', (event) => {
|
||||
receivedData = event.data as string;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
|
||||
// Wait for WebSocket connection to be established
|
||||
await waitFor(() => expect(connectionEstablished).toBe(true));
|
||||
|
||||
const onDataCallback = vi.mocked(mockTerminalInstance.onData!).mock
|
||||
.calls[0]![0] as (data: string) => void;
|
||||
onDataCallback('test data');
|
||||
|
||||
await waitFor(() => expect(receivedData).toBe('test data'));
|
||||
});
|
||||
|
||||
it('closes WebSocket and disposes terminal when Ctrl+D is pressed', async () => {
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
|
||||
const onKeyCallback = vi.mocked(mockTerminalInstance.onKey!).mock
|
||||
.calls[0]![0] as (event: { domEvent: KeyboardEvent }) => void;
|
||||
onKeyCallback({
|
||||
domEvent: { ctrlKey: true, code: 'KeyD' } as KeyboardEvent,
|
||||
it('builds ws:// URL when location is http', () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { protocol: 'http:', host: 'localhost:3000' },
|
||||
writable: true,
|
||||
});
|
||||
render(<KubectlShellView />);
|
||||
expect(getTerminalProps().url).toBe(
|
||||
'ws://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1'
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
it('passes connect=true to Terminal', () => {
|
||||
render(<KubectlShellView />);
|
||||
expect(getTerminalProps().connect).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell state', () => {
|
||||
it('shows loading indicator when connecting', () => {
|
||||
render(<KubectlShellView />);
|
||||
triggerStateChange('connecting');
|
||||
expect(screen.getByText('Loading Terminal...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows disconnected panel when disconnected', () => {
|
||||
render(<KubectlShellView />);
|
||||
triggerStateChange('disconnected');
|
||||
expect(screen.getByText('Console disconnected')).toBeInTheDocument();
|
||||
});
|
||||
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles user typing in terminal', async () => {
|
||||
let receivedData: string | undefined;
|
||||
let connectionEstablished = false;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
connectionEstablished = true;
|
||||
client.addEventListener('message', (event) => {
|
||||
receivedData = event.data as string;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
|
||||
// Wait for WebSocket connection to be established
|
||||
await waitFor(() => expect(connectionEstablished).toBe(true));
|
||||
|
||||
const onDataCallback = vi.mocked(mockTerminalInstance.onData!).mock
|
||||
.calls[0]![0] as (data: string) => void;
|
||||
onDataCallback('kubectl get pods');
|
||||
|
||||
await waitFor(() => expect(receivedData).toBe('kubectl get pods'));
|
||||
});
|
||||
|
||||
it('handles Enter key in terminal', async () => {
|
||||
let receivedData: string | undefined;
|
||||
let connectionEstablished = false;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
connectionEstablished = true;
|
||||
client.addEventListener('message', (event) => {
|
||||
receivedData = event.data as string;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
|
||||
// Wait for WebSocket connection to be established
|
||||
await waitFor(() => expect(connectionEstablished).toBe(true));
|
||||
|
||||
const onDataCallback = vi.mocked(mockTerminalInstance.onData!).mock
|
||||
.calls[0]![0] as (data: string) => void;
|
||||
onDataCallback('\r');
|
||||
|
||||
await waitFor(() => expect(receivedData).toBe('\r'));
|
||||
});
|
||||
|
||||
it('sets up WebSocket event listeners when socket is created', async () => {
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
expect(clientConnection).toBeDefined();
|
||||
});
|
||||
|
||||
it('opens terminal when WebSocket connection opens', async () => {
|
||||
let serverConnection: ServerConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ server: wsServer }) => {
|
||||
serverConnection = wsServer;
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
await waitFor(() => expect(serverConnection).toBeDefined());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockTerminalInstance.setOption).toHaveBeenCalledWith(
|
||||
'cursorBlink',
|
||||
true
|
||||
);
|
||||
expect(mockTerminalInstance.focus).toHaveBeenCalled();
|
||||
expect(vi.mocked(fit)).toHaveBeenCalledWith(mockTerminalInstance);
|
||||
expect(mockTerminalInstance.writeln).toHaveBeenCalledWith(
|
||||
'#Run kubectl commands inside here'
|
||||
);
|
||||
expect(mockTerminalInstance.writeln).toHaveBeenCalledWith(
|
||||
'#e.g. kubectl get all'
|
||||
);
|
||||
expect(mockTerminalInstance.writeln).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('writes WebSocket message data to terminal', async () => {
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
client.send('terminal output');
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
|
||||
await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.writeUtf8).toHaveBeenCalled();
|
||||
it('calls terminalClose when state becomes disconnected', () => {
|
||||
render(<KubectlShellView />);
|
||||
triggerStateChange('disconnected');
|
||||
expect(vi.mocked(terminalClose)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const writeCall = vi.mocked(mockTerminalInstance.writeUtf8)?.mock.calls[0];
|
||||
expect(new TextDecoder().decode(writeCall![0] as Uint8Array)).toBe(
|
||||
'terminal output'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows disconnected state when WebSocket closes', async () => {
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
|
||||
clientConnection!.close();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Console disconnected')).toBeInTheDocument();
|
||||
});
|
||||
expect(vi.mocked(terminalClose)).toHaveBeenCalled();
|
||||
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows disconnected state when WebSocket errors', async () => {
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
client.close(1003, 'Test error');
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Console disconnected')).toBeInTheDocument();
|
||||
});
|
||||
expect(vi.mocked(terminalClose)).toHaveBeenCalled();
|
||||
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show error notification when WebSocket error occurs and socket is closed', async () => {
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
client.close();
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Console disconnected')).toBeInTheDocument();
|
||||
});
|
||||
expect(vi.mocked(notifyError)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders reload button in disconnected state', async () => {
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
clientConnection!.close();
|
||||
|
||||
await waitFor(() => {
|
||||
const reloadButton = screen.getByTestId('k8sShell-reloadButton');
|
||||
expect(reloadButton).toBeInTheDocument();
|
||||
expect(reloadButton).toHaveTextContent('Reload');
|
||||
it('shows nothing initially (idle state)', () => {
|
||||
render(<KubectlShellView />);
|
||||
expect(screen.queryByText('Loading Terminal...')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Console disconnected')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders close button in disconnected state', async () => {
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
clientConnection!.close();
|
||||
|
||||
await waitFor(() => {
|
||||
const closeButton = screen.getByTestId('k8sShell-closeButton');
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
expect(closeButton).toHaveTextContent('Close');
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads window when reload button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockReload = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...window.location, reload: mockReload },
|
||||
writable: true,
|
||||
describe('disconnected buttons', () => {
|
||||
beforeEach(() => {
|
||||
render(<KubectlShellView />);
|
||||
triggerStateChange('disconnected');
|
||||
});
|
||||
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
clientConnection!.close();
|
||||
|
||||
const reloadButton = await screen.findByTestId('k8sShell-reloadButton');
|
||||
expect(reloadButton).toHaveTextContent('Reload');
|
||||
|
||||
await user.click(reloadButton);
|
||||
expect(mockReload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes window when close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockClose = vi.fn();
|
||||
Object.defineProperty(window, 'close', {
|
||||
value: mockClose,
|
||||
writable: true,
|
||||
it('renders Reload button', () => {
|
||||
expect(screen.getByTestId('k8sShell-reloadButton')).toHaveTextContent(
|
||||
'Reload'
|
||||
);
|
||||
});
|
||||
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
it('renders Close button', () => {
|
||||
expect(screen.getByTestId('k8sShell-closeButton')).toHaveTextContent(
|
||||
'Close'
|
||||
);
|
||||
});
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
it('reloads page when Reload is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockReload = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { ...window.location, reload: mockReload },
|
||||
writable: true,
|
||||
});
|
||||
await user.click(screen.getByTestId('k8sShell-reloadButton'));
|
||||
expect(mockReload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
render(<KubectlShellView />);
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
clientConnection!.close();
|
||||
|
||||
const closeButton = await screen.findByTestId('k8sShell-closeButton');
|
||||
expect(closeButton).toHaveTextContent('Close');
|
||||
|
||||
await user.click(closeButton);
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes event listeners on unmount', async () => {
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
const { unmount } = render(<KubectlShellView />);
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith(
|
||||
'resize',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('fits terminal on window resize', async () => {
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
render(<KubectlShellView />);
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
|
||||
const resizeCallback = vi
|
||||
.mocked(window.addEventListener)
|
||||
.mock.calls.find(
|
||||
(call: unknown[]) => call[0] === 'resize'
|
||||
)![1] as () => void;
|
||||
|
||||
resizeCallback();
|
||||
|
||||
expect(vi.mocked(fit)).toHaveBeenCalledWith(mockTerminalInstance);
|
||||
});
|
||||
|
||||
it('cleans up resources on unmount', async () => {
|
||||
let clientConnection: ClientConnection | undefined;
|
||||
|
||||
server.use(
|
||||
wssLink.addEventListener('connection', ({ client }) => {
|
||||
clientConnection = client;
|
||||
})
|
||||
);
|
||||
|
||||
const { unmount } = render(<KubectlShellView />);
|
||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||||
expect(window.removeEventListener).toHaveBeenCalledWith(
|
||||
'resize',
|
||||
expect.any(Function)
|
||||
);
|
||||
it('closes window when Close is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockClose = vi.fn();
|
||||
Object.defineProperty(window, 'close', {
|
||||
value: mockClose,
|
||||
writable: true,
|
||||
});
|
||||
await user.click(screen.getByTestId('k8sShell-closeButton'));
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,120 +1,22 @@
|
||||
import { Terminal } from 'xterm';
|
||||
import { fit } from 'xterm/lib/addons/fit/fit';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||
import { terminalClose } from '@/portainer/services/terminal-window';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { Alert } from '@@/Alert';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
type Socket = WebSocket | null;
|
||||
type ShellState = 'loading' | 'connected' | 'disconnected';
|
||||
import { Terminal, LINUX_SHELL_INIT_COMMANDS } from '@@/Terminal/Terminal';
|
||||
import type { ShellState } from '@@/Terminal/Terminal';
|
||||
|
||||
export function KubectlShellView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const [terminal] = useState(new Terminal());
|
||||
|
||||
const [socket, setSocket] = useState<Socket>(null);
|
||||
const [shellState, setShellState] = useState<ShellState>('loading');
|
||||
|
||||
const terminalElem = useRef(null);
|
||||
|
||||
const closeTerminal = useCallback(() => {
|
||||
terminalClose(); // only css trick
|
||||
socket?.close();
|
||||
terminal.dispose();
|
||||
setShellState('disconnected');
|
||||
}, [terminal, socket]);
|
||||
|
||||
const openTerminal = useCallback(() => {
|
||||
if (!terminalElem.current) {
|
||||
return;
|
||||
}
|
||||
terminal.open(terminalElem.current);
|
||||
terminal.setOption('cursorBlink', true);
|
||||
terminal.focus();
|
||||
fit(terminal);
|
||||
terminal.writeln('#Run kubectl commands inside here');
|
||||
terminal.writeln('#e.g. kubectl get all');
|
||||
terminal.writeln('');
|
||||
setShellState('connected');
|
||||
}, [terminal]);
|
||||
|
||||
const resizeTerminal = useCallback(() => {
|
||||
fit(terminal);
|
||||
}, [terminal]);
|
||||
|
||||
// refresh socket listeners on socket updates
|
||||
useEffect(() => {
|
||||
if (!socket) {
|
||||
return () => {};
|
||||
}
|
||||
function onOpen() {
|
||||
openTerminal();
|
||||
}
|
||||
function onMessage(e: MessageEvent) {
|
||||
const encoded = new TextEncoder().encode(e.data);
|
||||
terminal.writeUtf8(encoded);
|
||||
}
|
||||
function onClose() {
|
||||
closeTerminal();
|
||||
}
|
||||
function onError(e: Event) {
|
||||
closeTerminal();
|
||||
if (socket?.readyState !== WebSocket.CLOSED) {
|
||||
notifyError(
|
||||
'Failure',
|
||||
e as unknown as Error,
|
||||
'Websocket connection error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
socket.addEventListener('open', onOpen);
|
||||
socket.addEventListener('message', onMessage);
|
||||
socket.addEventListener('close', onClose);
|
||||
socket.addEventListener('error', onError);
|
||||
|
||||
return () => {
|
||||
socket.removeEventListener('open', onOpen);
|
||||
socket.removeEventListener('message', onMessage);
|
||||
socket.removeEventListener('close', onClose);
|
||||
socket.removeEventListener('error', onError);
|
||||
};
|
||||
}, [closeTerminal, openTerminal, socket, terminal]);
|
||||
|
||||
// on component load/destroy
|
||||
useEffect(() => {
|
||||
const socket = new WebSocket(buildUrl(environmentId));
|
||||
setSocket(socket);
|
||||
setShellState('loading');
|
||||
|
||||
terminal.onData((data) => socket.send(data));
|
||||
terminal.onKey(({ domEvent }) => {
|
||||
if (domEvent.ctrlKey && domEvent.code === 'KeyD') {
|
||||
close();
|
||||
setShellState('disconnected');
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', resizeTerminal);
|
||||
|
||||
function close() {
|
||||
socket.close();
|
||||
terminal.dispose();
|
||||
window.removeEventListener('resize', resizeTerminal);
|
||||
}
|
||||
|
||||
return close;
|
||||
}, [environmentId, terminal, resizeTerminal]);
|
||||
const [shellState, setShellState] = useState<ShellState>('idle');
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 top-0 z-[10000] bg-black text-white">
|
||||
{shellState === 'loading' && (
|
||||
{shellState === 'connecting' && (
|
||||
<div className="px-4 pt-2">Loading Terminal...</div>
|
||||
)}
|
||||
{shellState === 'disconnected' && (
|
||||
@@ -138,10 +40,22 @@ export function KubectlShellView() {
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-full" ref={terminalElem} />
|
||||
<Terminal
|
||||
url={buildUrl(environmentId)}
|
||||
connect
|
||||
onStateChange={onStateChange}
|
||||
initialCommands={LINUX_SHELL_INIT_COMMANDS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
function onStateChange(state: ShellState) {
|
||||
if (state === 'disconnected') {
|
||||
terminalClose();
|
||||
}
|
||||
setShellState(state);
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId) {
|
||||
const params = {
|
||||
endpointId: environmentId,
|
||||
|
||||
@@ -135,7 +135,8 @@
|
||||
"toastr": "^2.1.4",
|
||||
"ts-xor": "^1.1.0",
|
||||
"uuid": "^3.3.2",
|
||||
"xterm": "^3.8.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"yaml": "^1.10.2",
|
||||
"yup": "^0.32.11",
|
||||
"zustand": "^4.1.1"
|
||||
|
||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -114,6 +114,12 @@ importers:
|
||||
'@wojtekmaj/react-daterange-picker':
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0(@types/react-dom@17.0.25)(@types/react@17.0.75)(react-dom@17.0.2(react@17.0.2))(react@17.0.2)
|
||||
'@xterm/addon-fit':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
angular:
|
||||
specifier: 1.8.2
|
||||
version: 1.8.2
|
||||
@@ -330,9 +336,6 @@ importers:
|
||||
uuid:
|
||||
specifier: ^3.3.2
|
||||
version: 3.4.0
|
||||
xterm:
|
||||
specifier: ^3.8.0
|
||||
version: 3.14.5
|
||||
yaml:
|
||||
specifier: ^1.10.2
|
||||
version: 1.10.2
|
||||
@@ -3550,6 +3553,12 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@xterm/addon-fit@0.11.0':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
'@xterm/xterm@6.0.0':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
|
||||
'@xtuc/ieee754@1.2.0':
|
||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||
|
||||
@@ -8486,10 +8495,6 @@ packages:
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
xterm@3.14.5:
|
||||
resolution: {integrity: sha512-DVmQ8jlEtL+WbBKUZuMxHMBgK/yeIZwkXB81bH+MGaKKnJGYwA+770hzhXPfwEIokK9On9YIFPRleVp/5G7z9g==}
|
||||
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
|
||||
|
||||
xterm@4.16.0:
|
||||
resolution: {integrity: sha512-nAbuigL9CYkI075mdfqpnB8cHZNKxENCj1CQ9Tm5gSvWkMtkanmRN2mkHGjSaET1/3+X9BqISFFo7Pd2mXVjiQ==}
|
||||
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
|
||||
@@ -11810,6 +11815,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react-dom'
|
||||
|
||||
'@xterm/addon-fit@0.11.0': {}
|
||||
|
||||
'@xterm/xterm@6.0.0': {}
|
||||
|
||||
'@xtuc/ieee754@1.2.0': {}
|
||||
|
||||
'@xtuc/long@4.2.2': {}
|
||||
@@ -17242,8 +17251,6 @@ snapshots:
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
xterm@3.14.5: {}
|
||||
|
||||
xterm@4.16.0: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
Reference in New Issue
Block a user