feat(ui): create shared terminal component [BE-12697] (#1979)
This commit is contained in:
@@ -151,6 +151,7 @@ overrides:
|
|||||||
no-restricted-imports: off
|
no-restricted-imports: off
|
||||||
'react/jsx-props-no-spreading': off
|
'react/jsx-props-no-spreading': off
|
||||||
'@vitest/no-conditional-expect': warn
|
'@vitest/no-conditional-expect': warn
|
||||||
|
'max-classes-per-file': off
|
||||||
- files:
|
- files:
|
||||||
- app/**/*.stories.*
|
- app/**/*.stories.*
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
@@ -287,11 +287,6 @@ input[type='checkbox'] {
|
|||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.interactive {
|
.interactive {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'bootstrap/dist/css/bootstrap.css';
|
import 'bootstrap/dist/css/bootstrap.css';
|
||||||
import 'toastr/build/toastr.css';
|
import 'toastr/build/toastr.css';
|
||||||
import 'xterm/dist/xterm.css';
|
|
||||||
import 'angularjs-slider/dist/rzslider.css';
|
import 'angularjs-slider/dist/rzslider.css';
|
||||||
import 'angular-loading-bar/build/loading-bar.css';
|
import 'angular-loading-bar/build/loading-bar.css';
|
||||||
import 'angular-moment-picker/dist/angular-moment-picker.min.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 { csrfInterceptor, csrfTokenReaderInterceptorAngular } from './portainer/services/csrf';
|
||||||
import { agentInterceptor } from './portainer/services/axios';
|
import { agentInterceptor } from './portainer/services/axios';
|
||||||
import { dispatchCacheRefreshEventIfNeeded } from './portainer/services/http-request.helper';
|
import { dispatchCacheRefreshEventIfNeeded } from './portainer/services/http-request.helper';
|
||||||
@@ -33,8 +31,6 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService
|
|||||||
request: csrfInterceptor,
|
request: csrfInterceptor,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
Terminal.applyAddon(fit);
|
|
||||||
|
|
||||||
$uibTooltipProvider.setTriggers({
|
$uibTooltipProvider.setTriggers({
|
||||||
mouseenter: 'mouseleave',
|
mouseenter: 'mouseleave',
|
||||||
click: 'click',
|
click: 'click',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import tokenize from '@nxmix/tokenize-ansi';
|
import tokenize from '@nxmix/tokenize-ansi';
|
||||||
import { FontWeight } from 'xterm';
|
import { FontWeight } from '@xterm/xterm';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
colors,
|
colors,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FontWeight } from 'xterm';
|
import { FontWeight } from '@xterm/xterm';
|
||||||
|
|
||||||
import { type TextColor } from './colors';
|
import { type TextColor } from './colors';
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,6 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import { Terminal } from 'xterm';
|
|
||||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||||
import { commandStringToArray } from '@/docker/helpers/containers';
|
import { commandStringToArray } from '@/docker/helpers/containers';
|
||||||
|
import { isLinuxTerminalCommand, LINUX_SHELL_INIT_COMMANDS } from '@@/Terminal/Terminal';
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('ContainerConsoleController', [
|
angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||||
'$scope',
|
'$scope',
|
||||||
'$state',
|
'$state',
|
||||||
'$transition$',
|
'$transition$',
|
||||||
'ContainerService',
|
'ContainerService',
|
||||||
|
'ExecService',
|
||||||
'ImageService',
|
'ImageService',
|
||||||
'Notifications',
|
'Notifications',
|
||||||
'ExecService',
|
|
||||||
'HttpRequestHelper',
|
'HttpRequestHelper',
|
||||||
'CONSOLE_COMMANDS_LABEL_PREFIX',
|
'CONSOLE_COMMANDS_LABEL_PREFIX',
|
||||||
'SidebarService',
|
|
||||||
'endpoint',
|
'endpoint',
|
||||||
function ($scope, $state, $transition$, ContainerService, ImageService, Notifications, ExecService, HttpRequestHelper, CONSOLE_COMMANDS_LABEL_PREFIX, SidebarService, endpoint) {
|
function ($scope, $state, $transition$, ContainerService, ExecService, ImageService, Notifications, HttpRequestHelper, CONSOLE_COMMANDS_LABEL_PREFIX, endpoint) {
|
||||||
var socket, term;
|
const states = Object.freeze({
|
||||||
|
|
||||||
let states = Object.freeze({
|
|
||||||
disconnected: 0,
|
disconnected: 0,
|
||||||
connecting: 1,
|
connecting: 1,
|
||||||
connected: 2,
|
connected: 2,
|
||||||
@@ -26,15 +23,29 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
|||||||
$scope.loaded = false;
|
$scope.loaded = false;
|
||||||
$scope.states = states;
|
$scope.states = states;
|
||||||
$scope.state = states.disconnected;
|
$scope.state = states.disconnected;
|
||||||
|
|
||||||
$scope.formValues = {};
|
$scope.formValues = {};
|
||||||
$scope.containerCommands = [];
|
$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.$on('$destroy', function () {
|
||||||
$scope.disconnect();
|
$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 () {
|
$scope.connectAttach = function () {
|
||||||
if ($scope.state > states.disconnected) {
|
if ($scope.state > states.disconnected) {
|
||||||
return;
|
return;
|
||||||
@@ -42,7 +53,7 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
|||||||
|
|
||||||
$scope.state = states.connecting;
|
$scope.state = states.connecting;
|
||||||
|
|
||||||
let attachId = $transition$.params().id;
|
const attachId = $transition$.params().id;
|
||||||
|
|
||||||
ContainerService.container(endpoint.Id, attachId)
|
ContainerService.container(endpoint.Id, attachId)
|
||||||
.then((details) => {
|
.then((details) => {
|
||||||
@@ -52,22 +63,13 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = {
|
$scope.onShellResize = function ({ rows, cols }) {
|
||||||
endpointId: $state.params.endpointId,
|
ContainerService.resizeTTY(endpoint.Id, attachId, cols, rows);
|
||||||
id: attachId,
|
|
||||||
};
|
};
|
||||||
|
$scope.shellUrl = buildShellUrl('api/websocket/attach', { endpointId: $state.params.endpointId, id: attachId });
|
||||||
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
|
$scope.shellConnect = true;
|
||||||
var url =
|
|
||||||
base +
|
|
||||||
'api/websocket/attach?' +
|
|
||||||
Object.keys(params)
|
|
||||||
.map((k) => k + '=' + params[k])
|
|
||||||
.join('&');
|
|
||||||
|
|
||||||
initTerm(url, ContainerService.resizeTTY.bind(this, endpoint.Id, attachId));
|
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function (err) {
|
||||||
Notifications.error('Error', err, 'Unable to retrieve container details');
|
Notifications.error('Error', err, 'Unable to retrieve container details');
|
||||||
$scope.disconnect();
|
$scope.disconnect();
|
||||||
});
|
});
|
||||||
@@ -79,8 +81,9 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
$scope.state = states.connecting;
|
$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,
|
AttachStdin: true,
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
@@ -90,171 +93,48 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
|||||||
};
|
};
|
||||||
|
|
||||||
ContainerService.createExec(endpoint.Id, $transition$.params().id, execConfig)
|
ContainerService.createExec(endpoint.Id, $transition$.params().id, execConfig)
|
||||||
.then(function success(data) {
|
.then(function (data) {
|
||||||
const params = {
|
$scope.onShellResize = function ({ rows, cols }) {
|
||||||
endpointId: $state.params.endpointId,
|
ExecService.resizeTTY(data.Id, cols, rows);
|
||||||
id: data.Id,
|
|
||||||
};
|
};
|
||||||
|
if (isLinuxTerminalCommand(execConfig.Cmd[0])) {
|
||||||
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
|
$scope.shellInitCommands = LINUX_SHELL_INIT_COMMANDS;
|
||||||
var url =
|
}
|
||||||
base +
|
$scope.shellUrl = buildShellUrl('api/websocket/exec', { endpointId: $state.params.endpointId, id: data.Id });
|
||||||
'api/websocket/exec?' +
|
$scope.shellConnect = true;
|
||||||
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);
|
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function (err) {
|
||||||
Notifications.error('Failure', err, 'Unable to exec into container');
|
Notifications.error('Failure', err, 'Unable to exec into container');
|
||||||
$scope.disconnect();
|
$scope.disconnect();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.disconnect = function () {
|
$scope.disconnect = function () {
|
||||||
if (socket) {
|
$scope.shellConnect = false;
|
||||||
socket.close();
|
$scope.state = states.disconnected;
|
||||||
}
|
$scope.onShellResize = null;
|
||||||
if ($scope.state > states.disconnected) {
|
$scope.shellInitCommands = null;
|
||||||
$scope.state = states.disconnected;
|
|
||||||
if (term) {
|
|
||||||
term.write('\n\r(connection closed)');
|
|
||||||
term.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.autoconnectAttachView = function () {
|
$scope.autoconnectAttachView = function () {
|
||||||
return $scope.initView().then(function success() {
|
return $scope.initView().then(function () {
|
||||||
if ($scope.container.State.Running) {
|
if ($scope.container.State.Running) {
|
||||||
$scope.connectAttach();
|
$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 () {
|
$scope.initView = function () {
|
||||||
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
|
HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName);
|
||||||
return ContainerService.container(endpoint.Id, $transition$.params().id)
|
return ContainerService.container(endpoint.Id, $transition$.params().id)
|
||||||
.then(function success(data) {
|
.then(function (data) {
|
||||||
var container = data;
|
$scope.container = data;
|
||||||
$scope.container = container;
|
return ImageService.image(data.Image);
|
||||||
return ImageService.image(container.Image);
|
|
||||||
})
|
})
|
||||||
.then(function success(data) {
|
.then(function (data) {
|
||||||
var image = data;
|
const containerLabels = $scope.container.Config.Labels;
|
||||||
var containerLabels = $scope.container.Config.Labels;
|
$scope.imageOS = data.Os;
|
||||||
$scope.imageOS = image.Os;
|
$scope.formValues.command = data.Os === 'windows' ? 'powershell' : 'bash';
|
||||||
$scope.formValues.command = image.Os === 'windows' ? 'powershell' : 'bash';
|
|
||||||
$scope.containerCommands = Object.keys(containerLabels)
|
$scope.containerCommands = Object.keys(containerLabels)
|
||||||
.filter(function (label) {
|
.filter(function (label) {
|
||||||
return label.indexOf(CONSOLE_COMMANDS_LABEL_PREFIX) === 0;
|
return label.indexOf(CONSOLE_COMMANDS_LABEL_PREFIX) === 0;
|
||||||
@@ -267,7 +147,7 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
|||||||
});
|
});
|
||||||
$scope.loaded = true;
|
$scope.loaded = true;
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function (err) {
|
||||||
Notifications.error('Error', err, 'Unable to retrieve container details');
|
Notifications.error('Error', err, 'Unable to retrieve container details');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -277,5 +157,20 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
|||||||
$scope.formValues.isCustomCommand = enabled;
|
$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="row">
|
||||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { FallbackImage } from '@@/FallbackImage';
|
|||||||
import { BadgeIcon } from '@@/BadgeIcon';
|
import { BadgeIcon } from '@@/BadgeIcon';
|
||||||
import { TeamsSelector } from '@@/TeamsSelector';
|
import { TeamsSelector } from '@@/TeamsSelector';
|
||||||
import { TerminalTooltip } from '@@/TerminalTooltip';
|
import { TerminalTooltip } from '@@/TerminalTooltip';
|
||||||
|
import { Terminal } from '@@/Terminal/Terminal';
|
||||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
import { Slider } from '@@/form-components/Slider';
|
import { Slider } from '@@/form-components/Slider';
|
||||||
import { TagButton } from '@@/TagButton';
|
import { TagButton } from '@@/TagButton';
|
||||||
@@ -269,7 +270,17 @@ export const ngModule = angular
|
|||||||
'inlineLoader',
|
'inlineLoader',
|
||||||
r2a(InlineLoader, ['children', 'className', 'size'])
|
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;
|
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 });
|
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;
|
const msg = pickErrorMsg(e) || fallbackText;
|
||||||
saveNotification(title, msg, 'error');
|
saveNotification(title, msg, 'error');
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export function Notifications() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickErrorMsg(e?: Error) {
|
function pickErrorMsg(e?: unknown) {
|
||||||
if (!e) {
|
if (!e) {
|
||||||
return '';
|
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 { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
import { Terminal as TerminalIcon } from 'lucide-react';
|
import { Terminal as TerminalIcon } from 'lucide-react';
|
||||||
import { Terminal } from 'xterm';
|
|
||||||
|
|
||||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||||
import { notifyError } from '@/portainer/services/notifications';
|
|
||||||
import { TerminalTooltip } from '@/react/components/TerminalTooltip';
|
import { TerminalTooltip } from '@/react/components/TerminalTooltip';
|
||||||
|
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
@@ -12,10 +10,12 @@ import { Widget, WidgetBody } from '@@/Widget';
|
|||||||
import { Icon } from '@@/Icon';
|
import { Icon } from '@@/Icon';
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
import { Input } from '@@/form-components/Input';
|
import { Input } from '@@/form-components/Input';
|
||||||
|
import {
|
||||||
interface StringDictionary {
|
Terminal,
|
||||||
[index: string]: string;
|
isLinuxTerminalCommand,
|
||||||
}
|
LINUX_SHELL_INIT_COMMANDS,
|
||||||
|
} from '@@/Terminal/Terminal';
|
||||||
|
import type { ShellState } from '@@/Terminal/Terminal';
|
||||||
|
|
||||||
export function ConsoleView() {
|
export function ConsoleView() {
|
||||||
const {
|
const {
|
||||||
@@ -29,24 +29,17 @@ export function ConsoleView() {
|
|||||||
} = useCurrentStateAndParams();
|
} = useCurrentStateAndParams();
|
||||||
|
|
||||||
const [command, setCommand] = useState('/bin/sh');
|
const [command, setCommand] = useState('/bin/sh');
|
||||||
const [connectionStatus, setConnectionStatus] = useState('closed');
|
const [connect, setConnect] = useState(false);
|
||||||
const [terminal, setTerminal] = useState(null as Terminal | null);
|
const [shellState, setShellState] = useState<ShellState>('idle');
|
||||||
const [socket, setSocket] = useState(null as WebSocket | null);
|
|
||||||
|
|
||||||
const breadcrumbs = [
|
const breadcrumbs = [
|
||||||
{
|
{ label: 'Namespaces', link: 'kubernetes.resourcePools' },
|
||||||
label: 'Namespaces',
|
|
||||||
link: 'kubernetes.resourcePools',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: namespace,
|
label: namespace,
|
||||||
link: 'kubernetes.resourcePools.resourcePool',
|
link: 'kubernetes.resourcePools.resourcePool',
|
||||||
linkParams: { id: namespace },
|
linkParams: { id: namespace },
|
||||||
},
|
},
|
||||||
{
|
{ label: 'Applications', link: 'kubernetes.applications' },
|
||||||
label: 'Applications',
|
|
||||||
link: 'kubernetes.applications',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: appName,
|
label: appName,
|
||||||
link: 'kubernetes.applications.application',
|
link: 'kubernetes.applications.application',
|
||||||
@@ -59,51 +52,6 @@ export function ConsoleView() {
|
|||||||
'Console',
|
'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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -137,9 +85,7 @@ export function ConsoleView() {
|
|||||||
value={command}
|
value={command}
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
id="consoleCommand"
|
id="consoleCommand"
|
||||||
// disable eslint because we want to autofocus
|
disabled={connect}
|
||||||
// this is ok because we only have one input on the page
|
|
||||||
// https://portainer.atlassian.net/browse/EE-5752
|
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||||
autoFocus
|
autoFocus
|
||||||
data-cy="console-command-input"
|
data-cy="console-command-input"
|
||||||
@@ -150,17 +96,13 @@ export function ConsoleView() {
|
|||||||
<Button
|
<Button
|
||||||
className="btn btn-primary !ml-0"
|
className="btn btn-primary !ml-0"
|
||||||
data-cy="connect-console-button"
|
data-cy="connect-console-button"
|
||||||
onClick={
|
onClick={connect ? handleDisconnect : handleConnect}
|
||||||
connectionStatus === 'closed'
|
disabled={shellState === 'connecting'}
|
||||||
? connectConsole
|
|
||||||
: disconnectConsole
|
|
||||||
}
|
|
||||||
disabled={connectionStatus === 'connecting'}
|
|
||||||
>
|
>
|
||||||
{connectionStatus === 'open' && 'Disconnect'}
|
{shellState === 'connected' && 'Disconnect'}
|
||||||
{connectionStatus === 'connecting' && 'Connecting'}
|
{shellState === 'connecting' && 'Connecting'}
|
||||||
{connectionStatus !== 'connecting' &&
|
{shellState !== 'connecting' &&
|
||||||
connectionStatus !== 'open' &&
|
shellState !== 'connected' &&
|
||||||
'Connect'}
|
'Connect'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +110,16 @@ export function ConsoleView() {
|
|||||||
</Widget>
|
</Widget>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-12 p-0">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,8 +127,23 @@ export function ConsoleView() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
function connectConsole() {
|
function handleConnect() {
|
||||||
const params: StringDictionary = {
|
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,
|
endpointId: environmentId,
|
||||||
namespace,
|
namespace,
|
||||||
podName: podID,
|
podName: podID,
|
||||||
@@ -197,11 +163,6 @@ export function ConsoleView() {
|
|||||||
} else {
|
} else {
|
||||||
url = url.replace('http://', 'ws://');
|
url = url.replace('http://', 'ws://');
|
||||||
}
|
}
|
||||||
|
return url;
|
||||||
setConnectionStatus('connecting');
|
|
||||||
const term = new Terminal();
|
|
||||||
setTerminal(term);
|
|
||||||
const socket = new WebSocket(url);
|
|
||||||
setSocket(socket);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 userEvent from '@testing-library/user-event';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { Terminal } from 'xterm';
|
|
||||||
import { fit } from 'xterm/lib/addons/fit/fit';
|
|
||||||
|
|
||||||
import { terminalClose } from '@/portainer/services/terminal-window';
|
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';
|
import { KubectlShellView } from './KubectlShellView';
|
||||||
|
|
||||||
// Type helpers for MSW WebSocket connections
|
vi.mock('@@/Terminal/Terminal', () => ({
|
||||||
type WSConnection = Parameters<
|
Terminal: vi.fn(() => null),
|
||||||
Parameters<ReturnType<typeof ws.link>['addEventListener']>[1]
|
LINUX_SHELL_INIT_COMMANDS: [],
|
||||||
>[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('@/react/hooks/useEnvironmentId', () => ({
|
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||||
@@ -60,485 +26,120 @@ vi.mock('@/portainer/services/terminal-window', () => ({
|
|||||||
terminalClose: vi.fn(),
|
terminalClose: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/portainer/services/notifications', () => ({
|
function getTerminalProps() {
|
||||||
error: vi.fn(),
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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', {
|
Object.defineProperty(window, 'location', {
|
||||||
value: { protocol: 'https:', host: 'localhost:3000' },
|
value: { protocol: 'https:', host: 'localhost:3000' },
|
||||||
writable: true,
|
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', () => {
|
describe('KubectlShellView', () => {
|
||||||
it('renders loading state initially', () => {
|
describe('URL construction', () => {
|
||||||
render(<KubectlShellView />);
|
it('builds wss:// URL when location is https', () => {
|
||||||
expect(screen.getByText('Loading Terminal...')).toBeInTheDocument();
|
render(<KubectlShellView />);
|
||||||
});
|
expect(getTerminalProps().url).toBe(
|
||||||
|
'wss://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1'
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let connectionUrl: string | undefined;
|
it('builds ws:// URL when location is http', () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
server.use(
|
value: { protocol: 'http:', host: 'localhost:3000' },
|
||||||
wsLink.addEventListener('connection', ({ client }) => {
|
writable: true,
|
||||||
connectionUrl = client.url.toString();
|
});
|
||||||
})
|
render(<KubectlShellView />);
|
||||||
);
|
expect(getTerminalProps().url).toBe(
|
||||||
|
'ws://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1'
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(screen.getByText('Console disconnected')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles user typing in terminal', async () => {
|
it('calls terminalClose when state becomes disconnected', () => {
|
||||||
let receivedData: string | undefined;
|
render(<KubectlShellView />);
|
||||||
let connectionEstablished = false;
|
triggerStateChange('disconnected');
|
||||||
|
expect(vi.mocked(terminalClose)).toHaveBeenCalled();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const writeCall = vi.mocked(mockTerminalInstance.writeUtf8)?.mock.calls[0];
|
it('shows nothing initially (idle state)', () => {
|
||||||
expect(new TextDecoder().decode(writeCall![0] as Uint8Array)).toBe(
|
render(<KubectlShellView />);
|
||||||
'terminal output'
|
expect(screen.queryByText('Loading Terminal...')).not.toBeInTheDocument();
|
||||||
);
|
expect(
|
||||||
});
|
screen.queryByText('Console disconnected')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
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('renders close button in disconnected state', async () => {
|
describe('disconnected buttons', () => {
|
||||||
let clientConnection: ClientConnection | undefined;
|
beforeEach(() => {
|
||||||
|
render(<KubectlShellView />);
|
||||||
server.use(
|
triggerStateChange('disconnected');
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let clientConnection: ClientConnection | undefined;
|
it('renders Reload button', () => {
|
||||||
|
expect(screen.getByTestId('k8sShell-reloadButton')).toHaveTextContent(
|
||||||
server.use(
|
'Reload'
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let clientConnection: ClientConnection | undefined;
|
it('renders Close button', () => {
|
||||||
|
expect(screen.getByTestId('k8sShell-closeButton')).toHaveTextContent(
|
||||||
|
'Close'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
server.use(
|
it('reloads page when Reload is clicked', async () => {
|
||||||
wssLink.addEventListener('connection', ({ client }) => {
|
const user = userEvent.setup();
|
||||||
clientConnection = client;
|
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 />);
|
it('closes window when Close is clicked', async () => {
|
||||||
await waitFor(() => expect(clientConnection).toBeDefined());
|
const user = userEvent.setup();
|
||||||
clientConnection!.close();
|
const mockClose = vi.fn();
|
||||||
|
Object.defineProperty(window, 'close', {
|
||||||
const closeButton = await screen.findByTestId('k8sShell-closeButton');
|
value: mockClose,
|
||||||
expect(closeButton).toHaveTextContent('Close');
|
writable: true,
|
||||||
|
});
|
||||||
await user.click(closeButton);
|
await user.click(screen.getByTestId('k8sShell-closeButton'));
|
||||||
expect(mockClose).toHaveBeenCalled();
|
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)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,120 +1,22 @@
|
|||||||
import { Terminal } from 'xterm';
|
import { useState } from 'react';
|
||||||
import { fit } from 'xterm/lib/addons/fit/fit';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||||
import { terminalClose } from '@/portainer/services/terminal-window';
|
import { terminalClose } from '@/portainer/services/terminal-window';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { error as notifyError } from '@/portainer/services/notifications';
|
|
||||||
|
|
||||||
import { Alert } from '@@/Alert';
|
import { Alert } from '@@/Alert';
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
|
import { Terminal, LINUX_SHELL_INIT_COMMANDS } from '@@/Terminal/Terminal';
|
||||||
type Socket = WebSocket | null;
|
import type { ShellState } from '@@/Terminal/Terminal';
|
||||||
type ShellState = 'loading' | 'connected' | 'disconnected';
|
|
||||||
|
|
||||||
export function KubectlShellView() {
|
export function KubectlShellView() {
|
||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
const [terminal] = useState(new Terminal());
|
const [shellState, setShellState] = useState<ShellState>('idle');
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 top-0 z-[10000] bg-black text-white">
|
<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>
|
<div className="px-4 pt-2">Loading Terminal...</div>
|
||||||
)}
|
)}
|
||||||
{shellState === 'disconnected' && (
|
{shellState === 'disconnected' && (
|
||||||
@@ -138,10 +40,22 @@ export function KubectlShellView() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="h-full" ref={terminalElem} />
|
<Terminal
|
||||||
|
url={buildUrl(environmentId)}
|
||||||
|
connect
|
||||||
|
onStateChange={onStateChange}
|
||||||
|
initialCommands={LINUX_SHELL_INIT_COMMANDS}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function onStateChange(state: ShellState) {
|
||||||
|
if (state === 'disconnected') {
|
||||||
|
terminalClose();
|
||||||
|
}
|
||||||
|
setShellState(state);
|
||||||
|
}
|
||||||
|
|
||||||
function buildUrl(environmentId: EnvironmentId) {
|
function buildUrl(environmentId: EnvironmentId) {
|
||||||
const params = {
|
const params = {
|
||||||
endpointId: environmentId,
|
endpointId: environmentId,
|
||||||
|
|||||||
@@ -135,7 +135,8 @@
|
|||||||
"toastr": "^2.1.4",
|
"toastr": "^2.1.4",
|
||||||
"ts-xor": "^1.1.0",
|
"ts-xor": "^1.1.0",
|
||||||
"uuid": "^3.3.2",
|
"uuid": "^3.3.2",
|
||||||
"xterm": "^3.8.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"yaml": "^1.10.2",
|
"yaml": "^1.10.2",
|
||||||
"yup": "^0.32.11",
|
"yup": "^0.32.11",
|
||||||
"zustand": "^4.1.1"
|
"zustand": "^4.1.1"
|
||||||
|
|||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -114,6 +114,12 @@ importers:
|
|||||||
'@wojtekmaj/react-daterange-picker':
|
'@wojtekmaj/react-daterange-picker':
|
||||||
specifier: ^5.5.0
|
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)
|
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:
|
angular:
|
||||||
specifier: 1.8.2
|
specifier: 1.8.2
|
||||||
version: 1.8.2
|
version: 1.8.2
|
||||||
@@ -330,9 +336,6 @@ importers:
|
|||||||
uuid:
|
uuid:
|
||||||
specifier: ^3.3.2
|
specifier: ^3.3.2
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
xterm:
|
|
||||||
specifier: ^3.8.0
|
|
||||||
version: 3.14.5
|
|
||||||
yaml:
|
yaml:
|
||||||
specifier: ^1.10.2
|
specifier: ^1.10.2
|
||||||
version: 1.10.2
|
version: 1.10.2
|
||||||
@@ -3550,6 +3553,12 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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':
|
'@xtuc/ieee754@1.2.0':
|
||||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||||
|
|
||||||
@@ -8486,10 +8495,6 @@ packages:
|
|||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
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:
|
xterm@4.16.0:
|
||||||
resolution: {integrity: sha512-nAbuigL9CYkI075mdfqpnB8cHZNKxENCj1CQ9Tm5gSvWkMtkanmRN2mkHGjSaET1/3+X9BqISFFo7Pd2mXVjiQ==}
|
resolution: {integrity: sha512-nAbuigL9CYkI075mdfqpnB8cHZNKxENCj1CQ9Tm5gSvWkMtkanmRN2mkHGjSaET1/3+X9BqISFFo7Pd2mXVjiQ==}
|
||||||
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
|
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
|
||||||
@@ -11810,6 +11815,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react-dom'
|
- '@types/react-dom'
|
||||||
|
|
||||||
|
'@xterm/addon-fit@0.11.0': {}
|
||||||
|
|
||||||
|
'@xterm/xterm@6.0.0': {}
|
||||||
|
|
||||||
'@xtuc/ieee754@1.2.0': {}
|
'@xtuc/ieee754@1.2.0': {}
|
||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
@@ -17242,8 +17251,6 @@ snapshots:
|
|||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
xterm@3.14.5: {}
|
|
||||||
|
|
||||||
xterm@4.16.0: {}
|
xterm@4.16.0: {}
|
||||||
|
|
||||||
y18n@4.0.3: {}
|
y18n@4.0.3: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user