feat(ui): create shared terminal component [BE-12697] (#1979)

This commit is contained in:
Chaim Lev-Ari
2026-03-10 18:17:29 +02:00
committed by GitHub
parent 774e3d5948
commit 1007f1f740
19 changed files with 939 additions and 879 deletions

View File

@@ -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:

View File

@@ -287,11 +287,6 @@ input[type='checkbox'] {
margin: 0 !important;
}
.terminal-container {
width: 100%;
padding: 10px 0;
}
.interactive {
cursor: pointer;
}

View File

@@ -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';

View File

@@ -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',

View File

@@ -1,5 +1,5 @@
import tokenize from '@nxmix/tokenize-ansi';
import { FontWeight } from 'xterm';
import { FontWeight } from '@xterm/xterm';
import {
colors,

View File

@@ -1,4 +1,4 @@
import { FontWeight } from 'xterm';
import { FontWeight } from '@xterm/xterm';
import { type TextColor } from './colors';

View File

@@ -53,6 +53,6 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<div id="terminal-container" class="terminal-container"></div>
<shell-terminal url="shellUrl" connect="shellConnect" on-state-change="(onShellStateChange)" on-resize="(onShellResize)"></shell-terminal>
</div>
</div>

View File

@@ -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.shellConnect = false;
$scope.state = states.disconnected;
if (term) {
term.write('\n\r(connection closed)');
term.dispose();
}
}
$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://');
}
},
]);

View File

@@ -96,6 +96,6 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<div id="terminal-container" class="terminal-container"></div>
<shell-terminal url="shellUrl" connect="shellConnect" on-state-change="(onShellStateChange)" on-resize="(onShellResize)" initial-commands="shellInitCommands"></shell-terminal>
</div>
</div>

View File

@@ -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;

View File

@@ -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 '';
}

View 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);
}),
],
},
};

View 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();
});
});
});

View 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" />;
}

View File

@@ -1,10 +1,8 @@
import { useState, useCallback, useEffect } from 'react';
import { useState } from 'react';
import { useCurrentStateAndParams } from '@uirouter/react';
import { Terminal as TerminalIcon } from 'lucide-react';
import { Terminal } from 'xterm';
import { baseHref } from '@/portainer/helpers/pathHelper';
import { notifyError } from '@/portainer/services/notifications';
import { TerminalTooltip } from '@/react/components/TerminalTooltip';
import { PageHeader } from '@@/PageHeader';
@@ -12,10 +10,12 @@ import { Widget, WidgetBody } from '@@/Widget';
import { Icon } from '@@/Icon';
import { Button } from '@@/buttons';
import { Input } from '@@/form-components/Input';
interface StringDictionary {
[index: string]: string;
}
import {
Terminal,
isLinuxTerminalCommand,
LINUX_SHELL_INIT_COMMANDS,
} from '@@/Terminal/Terminal';
import type { ShellState } from '@@/Terminal/Terminal';
export function ConsoleView() {
const {
@@ -29,24 +29,17 @@ export function ConsoleView() {
} = useCurrentStateAndParams();
const [command, setCommand] = useState('/bin/sh');
const [connectionStatus, setConnectionStatus] = useState('closed');
const [terminal, setTerminal] = useState(null as Terminal | null);
const [socket, setSocket] = useState(null as WebSocket | null);
const [connect, setConnect] = useState(false);
const [shellState, setShellState] = useState<ShellState>('idle');
const breadcrumbs = [
{
label: 'Namespaces',
link: 'kubernetes.resourcePools',
},
{ label: 'Namespaces', link: 'kubernetes.resourcePools' },
{
label: namespace,
link: 'kubernetes.resourcePools.resourcePool',
linkParams: { id: namespace },
},
{
label: 'Applications',
link: 'kubernetes.applications',
},
{ label: 'Applications', link: 'kubernetes.applications' },
{
label: appName,
link: 'kubernetes.applications.application',
@@ -59,51 +52,6 @@ export function ConsoleView() {
'Console',
];
const disconnectConsole = useCallback(() => {
socket?.close();
terminal?.dispose();
setTerminal(null);
setSocket(null);
setConnectionStatus('closed');
}, [socket, terminal, setConnectionStatus]);
useEffect(() => {
if (socket) {
socket.onopen = () => {
const terminalContainer = document.getElementById('terminal-container');
if (terminalContainer) {
terminal?.open(terminalContainer);
terminal?.setOption('cursorBlink', true);
terminal?.focus();
setConnectionStatus('open');
socket.send('export LANG=C.UTF-8\n');
socket.send('export LC_ALL=C.UTF-8\n');
socket.send('clear\n');
}
};
socket.onmessage = (msg) => {
const encoded = new TextEncoder().encode(msg.data);
terminal?.writeUtf8(encoded);
};
socket.onerror = () => {
disconnectConsole();
notifyError('Websocket connection error');
};
socket.onclose = () => {
disconnectConsole();
};
}
}, [disconnectConsole, setConnectionStatus, socket, terminal]);
useEffect(() => {
terminal?.onData((data) => {
socket?.send(data);
});
}, [terminal, socket]);
return (
<>
<PageHeader
@@ -137,9 +85,7 @@ export function ConsoleView() {
value={command}
onChange={(e) => setCommand(e.target.value)}
id="consoleCommand"
// disable eslint because we want to autofocus
// this is ok because we only have one input on the page
// https://portainer.atlassian.net/browse/EE-5752
disabled={connect}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
data-cy="console-command-input"
@@ -150,17 +96,13 @@ export function ConsoleView() {
<Button
className="btn btn-primary !ml-0"
data-cy="connect-console-button"
onClick={
connectionStatus === 'closed'
? connectConsole
: disconnectConsole
}
disabled={connectionStatus === 'connecting'}
onClick={connect ? handleDisconnect : handleConnect}
disabled={shellState === 'connecting'}
>
{connectionStatus === 'open' && 'Disconnect'}
{connectionStatus === 'connecting' && 'Connecting'}
{connectionStatus !== 'connecting' &&
connectionStatus !== 'open' &&
{shellState === 'connected' && 'Disconnect'}
{shellState === 'connecting' && 'Connecting'}
{shellState !== 'connecting' &&
shellState !== 'connected' &&
'Connect'}
</Button>
</div>
@@ -168,7 +110,16 @@ export function ConsoleView() {
</Widget>
<div className="row">
<div className="col-sm-12 p-0">
<div id="terminal-container" className="terminal-container" />
<Terminal
url={buildUrl()}
connect={connect}
onStateChange={handleStateChange}
initialCommands={
isLinuxTerminalCommand(command)
? LINUX_SHELL_INIT_COMMANDS
: undefined
}
/>
</div>
</div>
</div>
@@ -176,8 +127,23 @@ export function ConsoleView() {
</>
);
function connectConsole() {
const params: StringDictionary = {
function handleConnect() {
setConnect(true);
}
function handleDisconnect() {
setConnect(false);
}
function handleStateChange(state: ShellState) {
if (state === 'disconnected') {
setConnect(false);
}
setShellState(state);
}
function buildUrl() {
const params: Record<string, string> = {
endpointId: environmentId,
namespace,
podName: podID,
@@ -197,11 +163,6 @@ export function ConsoleView() {
} else {
url = url.replace('http://', 'ws://');
}
setConnectionStatus('connecting');
const term = new Terminal();
setTerminal(term);
const socket = new WebSocket(url);
setSocket(socket);
return url;
}
}

View File

@@ -1,51 +1,17 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { Terminal } from 'xterm';
import { fit } from 'xterm/lib/addons/fit/fit';
import { terminalClose } from '@/portainer/services/terminal-window';
import { error as notifyError } from '@/portainer/services/notifications';
import { server, ws } from '@/setup-tests/server';
import { Terminal } from '@@/Terminal/Terminal';
import type { ShellState } from '@@/Terminal/Terminal';
import { KubectlShellView } from './KubectlShellView';
// Type helpers for MSW WebSocket connections
type WSConnection = Parameters<
Parameters<ReturnType<typeof ws.link>['addEventListener']>[1]
>[0];
type ClientConnection = WSConnection['client'];
type ServerConnection = WSConnection['server'];
// Shared WebSocket links for all tests
const wssLink = ws.link('wss://*/*');
const wsLink = ws.link('ws://*/*');
// Mock modules
vi.mock('xterm', () => ({
Terminal: vi.fn(
class {
open = vi.fn();
setOption = vi.fn();
focus = vi.fn();
writeln = vi.fn();
writeUtf8 = vi.fn();
onData = vi.fn();
onKey = vi.fn();
dispose = vi.fn();
}
),
}));
vi.mock('xterm/lib/addons/fit/fit', () => ({
fit: vi.fn(),
vi.mock('@@/Terminal/Terminal', () => ({
Terminal: vi.fn(() => null),
LINUX_SHELL_INIT_COMMANDS: [],
}));
vi.mock('@/react/hooks/useEnvironmentId', () => ({
@@ -60,485 +26,120 @@ vi.mock('@/portainer/services/terminal-window', () => ({
terminalClose: vi.fn(),
}));
vi.mock('@/portainer/services/notifications', () => ({
error: vi.fn(),
}));
function getTerminalProps() {
return vi.mocked(Terminal).mock.calls[0][0] as {
url: string;
connect: boolean;
onStateChange?: (state: ShellState) => void;
};
}
let mockTerminalInstance: Partial<Terminal>;
function triggerStateChange(state: ShellState) {
act(() => {
getTerminalProps().onStateChange?.(state);
});
}
beforeEach(() => {
vi.clearAllMocks();
mockTerminalInstance = {
open: vi.fn(),
setOption: vi.fn(),
focus: vi.fn(),
writeln: vi.fn(),
writeUtf8: vi.fn(),
onData: vi.fn(),
onKey: vi.fn(),
dispose: vi.fn(),
};
vi.mocked(Terminal).mockImplementation(function Terminal(this: Terminal) {
Object.assign(this, mockTerminalInstance);
});
Object.defineProperty(window, 'location', {
value: { protocol: 'https:', host: 'localhost:3000' },
writable: true,
});
Object.defineProperty(window, 'addEventListener', {
value: vi.fn(),
writable: true,
});
Object.defineProperty(window, 'removeEventListener', {
value: vi.fn(),
writable: true,
});
// Set up default echo handler for tests that don't need custom behavior
server.use(
wssLink.addEventListener('connection', ({ client, server }) => {
client.addEventListener('message', (event) => {
server.send(event.data);
});
})
);
});
describe('KubectlShellView', () => {
it('renders loading state initially', () => {
describe('URL construction', () => {
it('builds wss:// URL when location is https', () => {
render(<KubectlShellView />);
expect(screen.getByText('Loading Terminal...')).toBeInTheDocument();
});
it('creates WebSocket connection with correct URL', async () => {
let connectionUrl: string | undefined;
server.use(
wssLink.addEventListener('connection', ({ client }) => {
connectionUrl = client.url.toString();
})
);
render(<KubectlShellView />);
await waitFor(() => expect(connectionUrl).toBeDefined());
expect(connectionUrl).toBe(
expect(getTerminalProps().url).toBe(
'wss://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1'
);
});
it('creates WebSocket connection with ws protocol when location is http', async () => {
it('builds ws:// URL when location is http', () => {
Object.defineProperty(window, 'location', {
value: { protocol: 'http:', host: 'localhost:3000' },
writable: true,
});
let connectionUrl: string | undefined;
server.use(
wsLink.addEventListener('connection', ({ client }) => {
connectionUrl = client.url.toString();
})
);
render(<KubectlShellView />);
await waitFor(() => expect(connectionUrl).toBeDefined());
expect(connectionUrl).toBe(
expect(getTerminalProps().url).toBe(
'ws://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1'
);
});
it('sets up terminal event handlers on mount', () => {
it('passes connect=true to Terminal', () => {
render(<KubectlShellView />);
expect(mockTerminalInstance.onData).toHaveBeenCalled();
expect(mockTerminalInstance.onKey).toHaveBeenCalled();
expect(getTerminalProps().connect).toBe(true);
});
});
it('adds window resize listener on mount', () => {
describe('shell state', () => {
it('shows loading indicator when connecting', () => {
render(<KubectlShellView />);
expect(window.addEventListener).toHaveBeenCalledWith(
'resize',
expect.any(Function)
);
triggerStateChange('connecting');
expect(screen.getByText('Loading Terminal...')).toBeInTheDocument();
});
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;
});
})
);
it('shows disconnected panel when disconnected', () => {
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(() => {
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;
});
})
);
it('calls terminalClose when state becomes disconnected', () => {
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];
expect(new TextDecoder().decode(writeCall![0] as Uint8Array)).toBe(
'terminal output'
);
});
it('shows disconnected state when WebSocket closes', async () => {
let clientConnection: ClientConnection | undefined;
server.use(
wssLink.addEventListener('connection', ({ client }) => {
clientConnection = client;
})
);
render(<KubectlShellView />);
await waitFor(() => expect(clientConnection).toBeDefined());
clientConnection!.close();
await waitFor(() => {
expect(screen.getByText('Console disconnected')).toBeInTheDocument();
});
triggerStateChange('disconnected');
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');
})
);
it('shows nothing initially (idle state)', () => {
render(<KubectlShellView />);
await waitFor(() => {
expect(screen.getByText('Console disconnected')).toBeInTheDocument();
expect(screen.queryByText('Loading Terminal...')).not.toBeInTheDocument();
expect(
screen.queryByText('Console disconnected')
).not.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();
})
);
describe('disconnected buttons', () => {
beforeEach(() => {
render(<KubectlShellView />);
await waitFor(() => {
expect(screen.getByText('Console disconnected')).toBeInTheDocument();
});
expect(vi.mocked(notifyError)).not.toHaveBeenCalled();
triggerStateChange('disconnected');
});
it('renders reload button in disconnected state', async () => {
let clientConnection: ClientConnection | undefined;
server.use(
wssLink.addEventListener('connection', ({ client }) => {
clientConnection = client;
})
it('renders Reload button', () => {
expect(screen.getByTestId('k8sShell-reloadButton')).toHaveTextContent(
'Reload'
);
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 () => {
let clientConnection: ClientConnection | undefined;
server.use(
wssLink.addEventListener('connection', ({ client }) => {
clientConnection = client;
})
it('renders Close button', () => {
expect(screen.getByTestId('k8sShell-closeButton')).toHaveTextContent(
'Close'
);
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 () => {
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,
});
let clientConnection: ClientConnection | undefined;
server.use(
wssLink.addEventListener('connection', ({ client }) => {
clientConnection = client;
})
);
render(<KubectlShellView />);
await waitFor(() => expect(clientConnection).toBeDefined());
clientConnection!.close();
const reloadButton = await screen.findByTestId('k8sShell-reloadButton');
expect(reloadButton).toHaveTextContent('Reload');
await user.click(reloadButton);
await user.click(screen.getByTestId('k8sShell-reloadButton'));
expect(mockReload).toHaveBeenCalled();
});
it('closes window when close button is clicked', async () => {
it('closes window when Close is clicked', async () => {
const user = userEvent.setup();
const mockClose = vi.fn();
Object.defineProperty(window, 'close', {
value: mockClose,
writable: true,
});
let clientConnection: ClientConnection | undefined;
server.use(
wssLink.addEventListener('connection', ({ client }) => {
clientConnection = client;
})
);
render(<KubectlShellView />);
await waitFor(() => expect(clientConnection).toBeDefined());
clientConnection!.close();
const closeButton = await screen.findByTestId('k8sShell-closeButton');
expect(closeButton).toHaveTextContent('Close');
await user.click(closeButton);
await user.click(screen.getByTestId('k8sShell-closeButton'));
expect(mockClose).toHaveBeenCalled();
});
it('removes event listeners on unmount', async () => {
let clientConnection: ClientConnection | undefined;
server.use(
wssLink.addEventListener('connection', ({ client }) => {
clientConnection = client;
})
);
const { unmount } = render(<KubectlShellView />);
await waitFor(() => expect(clientConnection).toBeDefined());
unmount();
expect(window.removeEventListener).toHaveBeenCalledWith(
'resize',
expect.any(Function)
);
});
it('fits terminal on window resize', async () => {
let clientConnection: ClientConnection | undefined;
server.use(
wssLink.addEventListener('connection', ({ client }) => {
clientConnection = client;
})
);
render(<KubectlShellView />);
await waitFor(() => expect(clientConnection).toBeDefined());
const resizeCallback = vi
.mocked(window.addEventListener)
.mock.calls.find(
(call: unknown[]) => call[0] === 'resize'
)![1] as () => void;
resizeCallback();
expect(vi.mocked(fit)).toHaveBeenCalledWith(mockTerminalInstance);
});
it('cleans up resources on unmount', async () => {
let clientConnection: ClientConnection | undefined;
server.use(
wssLink.addEventListener('connection', ({ client }) => {
clientConnection = client;
})
);
const { unmount } = render(<KubectlShellView />);
await waitFor(() => expect(clientConnection).toBeDefined());
unmount();
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
expect(window.removeEventListener).toHaveBeenCalledWith(
'resize',
expect.any(Function)
);
});
});

View File

@@ -1,120 +1,22 @@
import { Terminal } from 'xterm';
import { fit } from 'xterm/lib/addons/fit/fit';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { baseHref } from '@/portainer/helpers/pathHelper';
import { terminalClose } from '@/portainer/services/terminal-window';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
import { Alert } from '@@/Alert';
import { Button } from '@@/buttons';
type Socket = WebSocket | null;
type ShellState = 'loading' | 'connected' | 'disconnected';
import { Terminal, LINUX_SHELL_INIT_COMMANDS } from '@@/Terminal/Terminal';
import type { ShellState } from '@@/Terminal/Terminal';
export function KubectlShellView() {
const environmentId = useEnvironmentId();
const [terminal] = useState(new Terminal());
const [socket, setSocket] = useState<Socket>(null);
const [shellState, setShellState] = useState<ShellState>('loading');
const terminalElem = useRef(null);
const closeTerminal = useCallback(() => {
terminalClose(); // only css trick
socket?.close();
terminal.dispose();
setShellState('disconnected');
}, [terminal, socket]);
const openTerminal = useCallback(() => {
if (!terminalElem.current) {
return;
}
terminal.open(terminalElem.current);
terminal.setOption('cursorBlink', true);
terminal.focus();
fit(terminal);
terminal.writeln('#Run kubectl commands inside here');
terminal.writeln('#e.g. kubectl get all');
terminal.writeln('');
setShellState('connected');
}, [terminal]);
const resizeTerminal = useCallback(() => {
fit(terminal);
}, [terminal]);
// refresh socket listeners on socket updates
useEffect(() => {
if (!socket) {
return () => {};
}
function onOpen() {
openTerminal();
}
function onMessage(e: MessageEvent) {
const encoded = new TextEncoder().encode(e.data);
terminal.writeUtf8(encoded);
}
function onClose() {
closeTerminal();
}
function onError(e: Event) {
closeTerminal();
if (socket?.readyState !== WebSocket.CLOSED) {
notifyError(
'Failure',
e as unknown as Error,
'Websocket connection error'
);
}
}
socket.addEventListener('open', onOpen);
socket.addEventListener('message', onMessage);
socket.addEventListener('close', onClose);
socket.addEventListener('error', onError);
return () => {
socket.removeEventListener('open', onOpen);
socket.removeEventListener('message', onMessage);
socket.removeEventListener('close', onClose);
socket.removeEventListener('error', onError);
};
}, [closeTerminal, openTerminal, socket, terminal]);
// on component load/destroy
useEffect(() => {
const socket = new WebSocket(buildUrl(environmentId));
setSocket(socket);
setShellState('loading');
terminal.onData((data) => socket.send(data));
terminal.onKey(({ domEvent }) => {
if (domEvent.ctrlKey && domEvent.code === 'KeyD') {
close();
setShellState('disconnected');
}
});
window.addEventListener('resize', resizeTerminal);
function close() {
socket.close();
terminal.dispose();
window.removeEventListener('resize', resizeTerminal);
}
return close;
}, [environmentId, terminal, resizeTerminal]);
const [shellState, setShellState] = useState<ShellState>('idle');
return (
<div className="fixed bottom-0 left-0 right-0 top-0 z-[10000] bg-black text-white">
{shellState === 'loading' && (
{shellState === 'connecting' && (
<div className="px-4 pt-2">Loading Terminal...</div>
)}
{shellState === 'disconnected' && (
@@ -138,10 +40,22 @@ export function KubectlShellView() {
</Alert>
</div>
)}
<div className="h-full" ref={terminalElem} />
<Terminal
url={buildUrl(environmentId)}
connect
onStateChange={onStateChange}
initialCommands={LINUX_SHELL_INIT_COMMANDS}
/>
</div>
);
function onStateChange(state: ShellState) {
if (state === 'disconnected') {
terminalClose();
}
setShellState(state);
}
function buildUrl(environmentId: EnvironmentId) {
const params = {
endpointId: environmentId,

View File

@@ -135,7 +135,8 @@
"toastr": "^2.1.4",
"ts-xor": "^1.1.0",
"uuid": "^3.3.2",
"xterm": "^3.8.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"yaml": "^1.10.2",
"yup": "^0.32.11",
"zustand": "^4.1.1"

25
pnpm-lock.yaml generated
View File

@@ -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: {}