From 1007f1f740cb3743557c23f05f124ed3a6577c22 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 10 Mar 2026 18:17:29 +0200 Subject: [PATCH] feat(ui): create shared terminal component [BE-12697] (#1979) --- .eslintrc.yml | 1 + app/assets/css/app.css | 5 - app/assets/css/index.js | 1 - app/config.js | 4 - app/docker/helpers/logHelper/formatLogs.ts | 2 +- app/docker/helpers/logHelper/types.ts | 2 +- .../views/containers/console/attach.html | 2 +- .../console/containerConsoleController.js | 237 ++----- app/docker/views/containers/console/exec.html | 2 +- app/portainer/react/components/index.ts | 13 +- app/portainer/services/notifications.ts | 4 +- .../components/Terminal/Terminal.stories.tsx | 186 ++++++ .../components/Terminal/Terminal.test.tsx | 340 ++++++++++ app/react/components/Terminal/Terminal.tsx | 153 +++++ .../applications/ConsoleView/ConsoleView.tsx | 131 ++-- .../KubectlShell/KubectlShellView.test.tsx | 585 +++--------------- .../cluster/KubectlShell/KubectlShellView.tsx | 122 +--- package.json | 3 +- pnpm-lock.yaml | 25 +- 19 files changed, 939 insertions(+), 879 deletions(-) create mode 100644 app/react/components/Terminal/Terminal.stories.tsx create mode 100644 app/react/components/Terminal/Terminal.test.tsx create mode 100644 app/react/components/Terminal/Terminal.tsx diff --git a/.eslintrc.yml b/.eslintrc.yml index 8e50130de..8e1dabcf3 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -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: diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 85d8fbca5..93c176dbe 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -287,11 +287,6 @@ input[type='checkbox'] { margin: 0 !important; } -.terminal-container { - width: 100%; - padding: 10px 0; -} - .interactive { cursor: pointer; } diff --git a/app/assets/css/index.js b/app/assets/css/index.js index 0fe541050..0b86ace7a 100644 --- a/app/assets/css/index.js +++ b/app/assets/css/index.js @@ -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'; diff --git a/app/config.js b/app/config.js index 3873105d5..9772a1ea8 100644 --- a/app/config.js +++ b/app/config.js @@ -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', diff --git a/app/docker/helpers/logHelper/formatLogs.ts b/app/docker/helpers/logHelper/formatLogs.ts index f60e61040..04d67aad1 100644 --- a/app/docker/helpers/logHelper/formatLogs.ts +++ b/app/docker/helpers/logHelper/formatLogs.ts @@ -1,5 +1,5 @@ import tokenize from '@nxmix/tokenize-ansi'; -import { FontWeight } from 'xterm'; +import { FontWeight } from '@xterm/xterm'; import { colors, diff --git a/app/docker/helpers/logHelper/types.ts b/app/docker/helpers/logHelper/types.ts index 7d467e296..4cf11f88c 100644 --- a/app/docker/helpers/logHelper/types.ts +++ b/app/docker/helpers/logHelper/types.ts @@ -1,4 +1,4 @@ -import { FontWeight } from 'xterm'; +import { FontWeight } from '@xterm/xterm'; import { type TextColor } from './colors'; diff --git a/app/docker/views/containers/console/attach.html b/app/docker/views/containers/console/attach.html index 6025b0981..6d3713be2 100644 --- a/app/docker/views/containers/console/attach.html +++ b/app/docker/views/containers/console/attach.html @@ -53,6 +53,6 @@
-
+
diff --git a/app/docker/views/containers/console/containerConsoleController.js b/app/docker/views/containers/console/containerConsoleController.js index 5cc792b08..da64d7c24 100644 --- a/app/docker/views/containers/console/containerConsoleController.js +++ b/app/docker/views/containers/console/containerConsoleController.js @@ -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://'); + } }, ]); diff --git a/app/docker/views/containers/console/exec.html b/app/docker/views/containers/console/exec.html index 9db38241d..025549f81 100644 --- a/app/docker/views/containers/console/exec.html +++ b/app/docker/views/containers/console/exec.html @@ -96,6 +96,6 @@
-
+
diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index feb79aeb5..9c759caff 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -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; diff --git a/app/portainer/services/notifications.ts b/app/portainer/services/notifications.ts index bfbebd3f6..77d6e923f 100644 --- a/app/portainer/services/notifications.ts +++ b/app/portainer/services/notifications.ts @@ -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 ''; } diff --git a/app/react/components/Terminal/Terminal.stories.tsx b/app/react/components/Terminal/Terminal.stories.tsx new file mode 100644 index 000000000..c407adb69 --- /dev/null +++ b/app/react/components/Terminal/Terminal.stories.tsx @@ -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> = { + 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 ( +
+ +
+ ); +} +AutoConnect.parameters = { + msw: { handlers: [createBashHandler()] }, +}; + +export function WithConnectButton() { + const [connect, setConnect] = useState(false); + const [state, setState] = useState('idle'); + + return ( +
+
+ + state: {state} +
+
+ +
+
+ ); +} +WithConnectButton.parameters = { + msw: { handlers: [createBashHandler()] }, +}; + +export function ServerDisconnects() { + return ( +
+ +
+ ); +} +ServerDisconnects.parameters = { + msw: { + handlers: [ + shellHandler.addEventListener('connection', ({ client }) => { + client.send('# Closing in 2s...\r\n'); + setTimeout(() => client.close(), 2000); + }), + ], + }, +}; diff --git a/app/react/components/Terminal/Terminal.test.tsx b/app/react/components/Terminal/Terminal.test.tsx new file mode 100644 index 000000000..4b804e305 --- /dev/null +++ b/app/react/components/Terminal/Terminal.test.tsx @@ -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['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; +let mockFitAddonInstance: Partial; +let mockResizeObserverObserve: ReturnType; +let mockResizeObserverDisconnect: ReturnType; +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(); + expect(vi.mocked(XTerm)).not.toHaveBeenCalled(); + }); + + it('calls onStateChange with connecting immediately when connect=true', () => { + const onStateChange = vi.fn(); + render(); + expect(onStateChange).toHaveBeenCalledWith('connecting'); + }); + + it('calls onStateChange with connected when socket opens', async () => { + const onStateChange = vi.fn(); + + render(); + + // 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(); + + await waitFor(() => expect(clientConnection).toBeDefined()); + clientConnection!.close(); + + await waitFor(() => { + expect(onStateChange).toHaveBeenCalledWith('disconnected'); + }); + }); + }); + + describe('terminal initialization', () => { + it('opens on socket open', async () => { + render(); + + await waitFor(() => { + expect(mockTerminalInstance.open).toHaveBeenCalled(); + }); + }); + + it('registers resize observer on container element', async () => { + render(); + + await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); + + expect(mockResizeObserverObserve).toHaveBeenCalledWith( + expect.any(HTMLElement) + ); + }); + + it('fits terminal when container resizes', async () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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( + + ); + expect(onResize).not.toHaveBeenCalled(); + + rerender(); + + await waitFor(() => + expect(onResize).toHaveBeenCalledWith({ rows: 24, cols: 80 }) + ); + }); + + it('calls onResize with terminal dimensions when container resizes', async () => { + const onResize = vi.fn(); + + render(); + + 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(); + + 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(); + + await waitFor(() => { + expect(onStateChange).toHaveBeenCalledWith('disconnected'); + }); + expect(vi.mocked(notifyError)).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/react/components/Terminal/Terminal.tsx b/app/react/components/Terminal/Terminal.tsx new file mode 100644 index 000000000..66154c2b9 --- /dev/null +++ b/app/react/components/Terminal/Terminal.tsx @@ -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(null); + const socketRef = useRef(null); + const termRef = useRef(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
; +} diff --git a/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx b/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx index 8a7fb6331..5cc0bb854 100644 --- a/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx +++ b/app/react/kubernetes/applications/ConsoleView/ConsoleView.tsx @@ -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('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 ( <> 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() {
@@ -168,7 +110,16 @@ export function ConsoleView() {
-
+
@@ -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 = { 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; } } diff --git a/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx b/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx index a516e5189..a615b262b 100644 --- a/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx +++ b/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx @@ -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['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; +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(); - 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(); - - 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(); + 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(); - - 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(); - expect(mockTerminalInstance.onData).toHaveBeenCalled(); - expect(mockTerminalInstance.onKey).toHaveBeenCalled(); - }); - - it('adds window resize listener on mount', () => { - render(); - 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(); - - // 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(); - 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(); + expect(getTerminalProps().url).toBe( + 'ws://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1' + ); }); - await waitFor(() => { + it('passes connect=true to Terminal', () => { + render(); + expect(getTerminalProps().connect).toBe(true); + }); + }); + + describe('shell state', () => { + it('shows loading indicator when connecting', () => { + render(); + triggerStateChange('connecting'); + expect(screen.getByText('Loading Terminal...')).toBeInTheDocument(); + }); + + it('shows disconnected panel when disconnected', () => { + render(); + 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(); - - // 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(); - - // 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(); - 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(); - 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(); - - await waitFor(() => expect(mockTerminalInstance.open).toHaveBeenCalled()); - - await waitFor(() => { - expect(mockTerminalInstance.writeUtf8).toHaveBeenCalled(); + it('calls terminalClose when state becomes disconnected', () => { + render(); + 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(); - 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(); - - 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(); - - 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(); - 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(); + 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(); - 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(); + triggerStateChange('disconnected'); }); - let clientConnection: ClientConnection | undefined; - - server.use( - wssLink.addEventListener('connection', ({ client }) => { - clientConnection = client; - }) - ); - - render(); - 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(); - 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(); - 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(); - 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(); - 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(); + }); }); }); diff --git a/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.tsx b/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.tsx index 521d873ea..0b087ac86 100644 --- a/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.tsx +++ b/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.tsx @@ -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(null); - const [shellState, setShellState] = useState('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('idle'); return (
- {shellState === 'loading' && ( + {shellState === 'connecting' && (
Loading Terminal...
)} {shellState === 'disconnected' && ( @@ -138,10 +40,22 @@ export function KubectlShellView() {
)} -
+
); + function onStateChange(state: ShellState) { + if (state === 'disconnected') { + terminalClose(); + } + setShellState(state); + } + function buildUrl(environmentId: EnvironmentId) { const params = { endpointId: environmentId, diff --git a/package.json b/package.json index c8f6d20cf..be509ae39 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96dd9cae9..1aef9b3da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}