diff --git a/app/index.html b/app/index.html
index 52b9b5d10..3045fe547 100644
--- a/app/index.html
+++ b/app/index.html
@@ -31,8 +31,8 @@
diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js
index adb937cce..5fb223a40 100644
--- a/app/kubernetes/__module.js
+++ b/app/kubernetes/__module.js
@@ -83,6 +83,13 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
});
}
+ // EE-5842: do not redirect shell views when the env is removed
+ const nextTransition = $state.transition && $state.transition.to();
+ const nextTransitionName = nextTransition ? nextTransition.name : '';
+ if (nextTransitionName === 'kubernetes.kubectlshell' && !endpoint) {
+ return;
+ }
+
const kubeTypes = [
PortainerEndpointTypes.KubernetesLocalEnvironment,
PortainerEndpointTypes.AgentOnKubernetesEnvironment,
@@ -120,6 +127,11 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
EndpointProvider.clean();
Notifications.error('Failed loading environment', e);
}
+ // Prevent redirect to home for shell views when environment is unreachable
+ // Show toast error instead (handled above in Notifications.error)
+ if (nextTransitionName === 'kubernetes.kubectlshell') {
+ return;
+ }
$state.go('portainer.home', params, { reload: true, inherit: false });
return false;
}
@@ -424,6 +436,17 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
},
};
+ const kubectlShell = {
+ name: 'kubernetes.kubectlshell',
+ url: '/kubectl-shell',
+ views: {
+ 'content@': {
+ component: 'kubectlShellView',
+ },
+ 'sidebar@': {},
+ },
+ };
+
const dashboard = {
name: 'kubernetes.dashboard',
url: '/dashboard',
@@ -657,6 +680,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
$stateRegistryProvider.register(deploy);
$stateRegistryProvider.register(node);
$stateRegistryProvider.register(nodeStats);
+ $stateRegistryProvider.register(kubectlShell);
$stateRegistryProvider.register(resourcePools);
$stateRegistryProvider.register(namespaceCreation);
$stateRegistryProvider.register(resourcePool);
diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts
index e4043985f..aeb161dcd 100644
--- a/app/kubernetes/react/views/index.ts
+++ b/app/kubernetes/react/views/index.ts
@@ -24,6 +24,7 @@ import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView'
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView';
+import { KubectlShellView } from '@/react/kubernetes/cluster/KubectlShell/KubectlShellView';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])
@@ -84,6 +85,10 @@ export const viewsModule = angular
'kubernetesHelmApplicationView',
r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), [])
)
+ .component(
+ 'kubectlShellView',
+ r2a(withUIRouter(withReactQuery(withCurrentUser(KubectlShellView))), [])
+ )
.component(
'kubernetesClusterView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), [])
diff --git a/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx b/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx
new file mode 100644
index 000000000..e8bd53818
--- /dev/null
+++ b/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.test.tsx
@@ -0,0 +1,456 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi, type Mock } 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 { KubectlShellView } from './KubectlShellView';
+
+// Mock modules first
+vi.mock('xterm', () => ({
+ Terminal: vi.fn(() => ({
+ open: vi.fn(),
+ setOption: vi.fn(),
+ focus: vi.fn(),
+ writeln: vi.fn(),
+ writeUtf8: vi.fn(),
+ onData: vi.fn(),
+ onKey: vi.fn(),
+ dispose: vi.fn(),
+ })),
+}));
+
+vi.mock('xterm/lib/addons/fit/fit', () => ({
+ fit: vi.fn(),
+}));
+
+vi.mock('@/react/hooks/useEnvironmentId', () => ({
+ useEnvironmentId: () => 1,
+}));
+
+vi.mock('@/portainer/helpers/pathHelper', () => ({
+ baseHref: vi.fn().mockReturnValue('/portainer/'),
+}));
+
+vi.mock('@/portainer/services/terminal-window', () => ({
+ terminalClose: vi.fn(),
+}));
+
+vi.mock('@/portainer/services/notifications', () => ({
+ error: vi.fn(),
+}));
+
+// Mock WebSocket globally
+const originalWebSocket = global.WebSocket;
+let mockWebSocket: {
+ send: Mock;
+ close: Mock;
+ addEventListener: Mock;
+ removeEventListener: Mock;
+ readyState: number;
+};
+let mockTerminalInstance: Partial
;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Create mock terminal instance
+ 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(),
+ };
+
+ // Mock Terminal constructor to return our mock instance
+ vi.mocked(Terminal).mockImplementation(
+ () => mockTerminalInstance as Terminal
+ );
+
+ // Create mock WebSocket instance
+ mockWebSocket = {
+ send: vi.fn(),
+ close: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ readyState: WebSocket.OPEN,
+ };
+
+ global.WebSocket = vi.fn(() => mockWebSocket) as unknown as typeof WebSocket;
+
+ // Reset window methods
+ 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,
+ });
+});
+
+afterEach(() => {
+ global.WebSocket = originalWebSocket;
+});
+
+describe('KubectlShellView', () => {
+ it('renders loading state initially', () => {
+ render();
+
+ expect(screen.getByText('Loading Terminal...')).toBeInTheDocument();
+ });
+
+ it('creates WebSocket connection with correct URL', () => {
+ render();
+
+ expect(global.WebSocket).toHaveBeenCalledWith(
+ 'wss://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1'
+ );
+ });
+
+ it('creates WebSocket connection with ws protocol when location is http', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ protocol: 'http:',
+ host: 'localhost:3000',
+ },
+ writable: true,
+ });
+
+ render();
+
+ expect(global.WebSocket).toHaveBeenCalledWith(
+ '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', () => {
+ render();
+
+ const onDataCallback = (mockTerminalInstance.onData as Mock).mock
+ .calls[0][0] as (data: string) => void;
+ onDataCallback('test data');
+
+ expect(mockWebSocket.send).toHaveBeenCalledWith('test data');
+ });
+
+ it('closes WebSocket and disposes terminal when Ctrl+D is pressed', () => {
+ render();
+
+ const onKeyCallback = (mockTerminalInstance.onKey as Mock).mock
+ .calls[0][0] as (event: { domEvent: KeyboardEvent }) => void;
+ onKeyCallback({
+ domEvent: {
+ ctrlKey: true,
+ code: 'KeyD',
+ } as KeyboardEvent,
+ });
+
+ expect(mockWebSocket.close).toHaveBeenCalled();
+ expect(mockTerminalInstance.dispose).toHaveBeenCalled();
+ });
+
+ it('handles user typing in terminal', () => {
+ render();
+
+ const onDataCallback = (mockTerminalInstance.onData as Mock).mock
+ .calls[0][0] as (data: string) => void;
+
+ // Simulate user typing a kubectl command
+ const userInput = 'kubectl get pods';
+ onDataCallback(userInput);
+
+ expect(mockWebSocket.send).toHaveBeenCalledWith(userInput);
+ });
+
+ it('handles Enter key in terminal', () => {
+ render();
+
+ const onDataCallback = (mockTerminalInstance.onData as Mock).mock
+ .calls[0][0] as (data: string) => void;
+
+ // Simulate user pressing Enter key
+ const enterKey = '\r';
+ onDataCallback(enterKey);
+
+ expect(mockWebSocket.send).toHaveBeenCalledWith(enterKey);
+ });
+
+ it('sets up WebSocket event listeners when socket is created', () => {
+ render();
+
+ expect(mockWebSocket.addEventListener).toHaveBeenCalledWith(
+ 'open',
+ expect.any(Function)
+ );
+ expect(mockWebSocket.addEventListener).toHaveBeenCalledWith(
+ 'message',
+ expect.any(Function)
+ );
+ expect(mockWebSocket.addEventListener).toHaveBeenCalledWith(
+ 'close',
+ expect.any(Function)
+ );
+ expect(mockWebSocket.addEventListener).toHaveBeenCalledWith(
+ 'error',
+ expect.any(Function)
+ );
+ });
+
+ it('opens terminal when WebSocket connection opens', () => {
+ render();
+
+ const openCallback = mockWebSocket.addEventListener.mock.calls.find(
+ (call: unknown[]) => call[0] === 'open'
+ )![1] as () => void;
+
+ openCallback();
+
+ 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', () => {
+ render();
+
+ const messageCallback = mockWebSocket.addEventListener.mock.calls.find(
+ (call: unknown[]) => call[0] === 'message'
+ )![1] as (event: MessageEvent) => void;
+
+ const mockEvent = { data: 'terminal output' } as MessageEvent;
+ messageCallback(mockEvent);
+
+ expect(mockTerminalInstance.writeUtf8).toHaveBeenCalled();
+ });
+
+ it('shows disconnected state when WebSocket closes', async () => {
+ render();
+
+ const closeCallback = mockWebSocket.addEventListener.mock.calls.find(
+ (call: unknown[]) => call[0] === 'close'
+ )![1] as () => void;
+
+ closeCallback();
+
+ await waitFor(() => {
+ expect(screen.getByText('Console disconnected')).toBeInTheDocument();
+ });
+
+ expect(vi.mocked(terminalClose)).toHaveBeenCalled();
+ expect(mockWebSocket.close).toHaveBeenCalled();
+ expect(mockTerminalInstance.dispose).toHaveBeenCalled();
+ });
+
+ it('shows disconnected state when WebSocket errors', async () => {
+ render();
+
+ const errorCallback = mockWebSocket.addEventListener.mock.calls.find(
+ (call: unknown[]) => call[0] === 'error'
+ )![1] as (event: Event) => void;
+
+ const mockError = new Event('error');
+ errorCallback(mockError);
+
+ await waitFor(() => {
+ expect(screen.getByText('Console disconnected')).toBeInTheDocument();
+ });
+
+ expect(vi.mocked(terminalClose)).toHaveBeenCalled();
+ expect(mockWebSocket.close).toHaveBeenCalled();
+ expect(mockTerminalInstance.dispose).toHaveBeenCalled();
+ });
+
+ it('does not show error notification when WebSocket error occurs and socket is closed', () => {
+ render();
+
+ // Set the WebSocket state to CLOSED
+ mockWebSocket.readyState = WebSocket.CLOSED;
+
+ const errorCallback = mockWebSocket.addEventListener.mock.calls.find(
+ (call: unknown[]) => call[0] === 'error'
+ )![1] as (event: Event) => void;
+
+ const mockError = new Event('error');
+ errorCallback(mockError);
+
+ expect(vi.mocked(notifyError)).not.toHaveBeenCalled();
+ });
+
+ it('renders reload button in disconnected state', async () => {
+ render();
+
+ const closeCallback = mockWebSocket.addEventListener.mock.calls.find(
+ (call: unknown[]) => call[0] === 'close'
+ )![1] as () => void;
+
+ closeCallback();
+
+ await waitFor(() => {
+ const reloadButton = screen.getByTestId('k8sShell-reloadButton');
+ expect(reloadButton).toBeInTheDocument();
+ expect(reloadButton).toHaveTextContent('Reload');
+ });
+ });
+
+ it('renders close button in disconnected state', async () => {
+ render();
+
+ const closeCallback = mockWebSocket.addEventListener.mock.calls.find(
+ (call: unknown[]) => call[0] === 'close'
+ )![1] as () => void;
+
+ closeCallback();
+
+ 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: { reload: mockReload },
+ writable: true,
+ });
+
+ render();
+
+ const closeCallback = mockWebSocket.addEventListener.mock.calls.find(
+ (call: unknown[]) => call[0] === 'close'
+ )![1] as () => void;
+
+ closeCallback();
+
+ // Wait for button to appear in disconnected state
+ const reloadButton = await screen.findByTestId('k8sShell-reloadButton');
+ expect(reloadButton).toHaveTextContent('Reload');
+
+ // Click the button
+ 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,
+ });
+
+ render();
+
+ const closeCallback = mockWebSocket.addEventListener.mock.calls.find(
+ (call: unknown[]) => call[0] === 'close'
+ )![1] as () => void;
+
+ closeCallback();
+
+ // Wait for button to appear in disconnected state
+ const closeButton = await screen.findByTestId('k8sShell-closeButton');
+ expect(closeButton).toHaveTextContent('Close');
+
+ // Click the button
+ await user.click(closeButton);
+ expect(mockClose).toHaveBeenCalled();
+ });
+
+ it('removes event listeners on unmount', () => {
+ const { unmount } = render();
+
+ unmount();
+
+ expect(mockWebSocket.removeEventListener).toHaveBeenCalledWith(
+ 'open',
+ expect.any(Function)
+ );
+ expect(mockWebSocket.removeEventListener).toHaveBeenCalledWith(
+ 'message',
+ expect.any(Function)
+ );
+ expect(mockWebSocket.removeEventListener).toHaveBeenCalledWith(
+ 'close',
+ expect.any(Function)
+ );
+ expect(mockWebSocket.removeEventListener).toHaveBeenCalledWith(
+ 'error',
+ expect.any(Function)
+ );
+ expect(window.removeEventListener).toHaveBeenCalledWith(
+ 'resize',
+ expect.any(Function)
+ );
+ });
+
+ it('fits terminal on window resize', () => {
+ render();
+
+ const resizeCallback = (window.addEventListener as Mock).mock.calls.find(
+ (call: unknown[]) => call[0] === 'resize'
+ )![1] as () => void;
+
+ resizeCallback();
+
+ expect(vi.mocked(fit)).toHaveBeenCalledWith(mockTerminalInstance);
+ });
+
+ it('cleans up resources on unmount', () => {
+ const { unmount } = render();
+
+ unmount();
+
+ expect(mockWebSocket.close).toHaveBeenCalled();
+ expect(mockTerminalInstance.dispose).toHaveBeenCalled();
+ expect(window.removeEventListener).toHaveBeenCalledWith(
+ 'resize',
+ expect.any(Function)
+ );
+ });
+});
diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx b/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.tsx
similarity index 52%
rename from app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx
rename to app/react/kubernetes/cluster/KubectlShell/KubectlShellView.tsx
index ac609983e..521d873ea 100644
--- a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx
+++ b/app/react/kubernetes/cluster/KubectlShell/KubectlShellView.tsx
@@ -1,56 +1,39 @@
import { Terminal } from 'xterm';
import { fit } from 'xterm/lib/addons/fit/fit';
import { useCallback, useEffect, useRef, useState } from 'react';
-import clsx from 'clsx';
-import { RotateCw, X, Terminal as TerminalIcon } from 'lucide-react';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { baseHref } from '@/portainer/helpers/pathHelper';
-import {
- terminalClose,
- terminalResize,
-} from '@/portainer/services/terminal-window';
+import { terminalClose } from '@/portainer/services/terminal-window';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
-import { Icon } from '@@/Icon';
+import { Alert } from '@@/Alert';
import { Button } from '@@/buttons';
-import styles from './KubectlShell.module.css';
+type Socket = WebSocket | null;
+type ShellState = 'loading' | 'connected' | 'disconnected';
-interface ShellState {
- socket: WebSocket | null;
- minimized: boolean;
-}
-
-interface Props {
- environmentId: EnvironmentId;
- onClose(): void;
-}
-
-export function KubeCtlShell({ environmentId, onClose }: Props) {
+export function KubectlShellView() {
+ const environmentId = useEnvironmentId();
const [terminal] = useState(new Terminal());
- const [shell, setShell] = useState({
- socket: null,
- minimized: false,
- });
-
- const { socket } = shell;
+ const [socket, setSocket] = useState(null);
+ const [shellState, setShellState] = useState('loading');
const terminalElem = useRef(null);
- const handleClose = useCallback(() => {
+ const closeTerminal = useCallback(() => {
terminalClose(); // only css trick
socket?.close();
terminal.dispose();
- onClose();
- }, [onClose, terminal, socket]);
+ setShellState('disconnected');
+ }, [terminal, socket]);
const openTerminal = useCallback(() => {
if (!terminalElem.current) {
return;
}
-
terminal.open(terminalElem.current);
terminal.setOption('cursorBlink', true);
terminal.focus();
@@ -58,6 +41,11 @@ export function KubeCtlShell({ environmentId, onClose }: Props) {
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
@@ -73,10 +61,10 @@ export function KubeCtlShell({ environmentId, onClose }: Props) {
terminal.writeUtf8(encoded);
}
function onClose() {
- handleClose();
+ closeTerminal();
}
function onError(e: Event) {
- handleClose();
+ closeTerminal();
if (socket?.readyState !== WebSocket.CLOSED) {
notifyError(
'Failure',
@@ -97,89 +85,63 @@ export function KubeCtlShell({ environmentId, onClose }: Props) {
socket.removeEventListener('close', onClose);
socket.removeEventListener('error', onError);
};
- }, [handleClose, openTerminal, socket, terminal]);
+ }, [closeTerminal, openTerminal, socket, terminal]);
// on component load/destroy
useEffect(() => {
const socket = new WebSocket(buildUrl(environmentId));
- setShell((shell) => ({ ...shell, socket }));
+ 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', () => terminalResize());
+ window.addEventListener('resize', resizeTerminal);
function close() {
socket.close();
terminal.dispose();
- window.removeEventListener('resize', terminalResize);
+ window.removeEventListener('resize', resizeTerminal);
}
return close;
- }, [environmentId, terminal]);
+ }, [environmentId, terminal, resizeTerminal]);
return (
-
-
-
-
- kubectl shell
+
+ {shellState === 'loading' && (
+
Loading Terminal...
+ )}
+ {shellState === 'disconnected' && (
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+ )}
+
);
- function clearScreen() {
- terminal.clear();
- }
-
- function toggleMinimize() {
- if (shell.minimized) {
- terminalResize();
- setShell((shell) => ({ ...shell, minimized: false }));
- } else {
- terminalClose();
- setShell((shell) => ({ ...shell, minimized: true }));
- }
- }
-
function buildUrl(environmentId: EnvironmentId) {
const params = {
endpointId: environmentId,
diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.module.css b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.module.css
deleted file mode 100644
index ae491fa46..000000000
--- a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.module.css
+++ /dev/null
@@ -1,47 +0,0 @@
-.root {
- position: fixed;
- background: #000;
- bottom: 0;
- left: 0;
- width: 100vw;
- z-index: 1000;
- height: 495px;
- border-top-left-radius: 8px;
- border-top-right-radius: 8px;
-}
-
-.root.minimized {
- height: 35px;
- border-top-left-radius: 8px;
- border-top-right-radius: 8px;
-}
-
-.header {
- height: 35px;
- border-top-left-radius: 8px;
- border-top-right-radius: 8px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- color: #424242;
- background: rgb(245, 245, 245);
- border-top: 1px solid rgb(190, 190, 190);
-
- padding: 0 16px;
-}
-
-.title {
- font-weight: 500;
- font-size: 14px;
-}
-
-.actions button {
- padding: 0;
- border: 0;
-}
-
-.terminal-container .loading-message {
- position: fixed;
- padding: 10px 16px 0px 16px;
- color: #fff;
-}
diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/index.ts b/app/react/sidebar/KubernetesSidebar/KubectlShell/index.ts
deleted file mode 100644
index 541c38e1f..000000000
--- a/app/react/sidebar/KubernetesSidebar/KubectlShell/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { KubectlShellButton } from './KubectlShellButton';
diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShellButton.test.tsx b/app/react/sidebar/KubernetesSidebar/KubectlShellButton.test.tsx
new file mode 100644
index 000000000..785015347
--- /dev/null
+++ b/app/react/sidebar/KubernetesSidebar/KubectlShellButton.test.tsx
@@ -0,0 +1,127 @@
+import { render, fireEvent, screen } from '@testing-library/react';
+import { vi } from 'vitest';
+import { PropsWithChildren, useMemo } from 'react';
+
+import { useAnalytics } from '@/react/hooks/useAnalytics';
+
+import { Context } from '../useSidebarState';
+
+import { KubectlShellButton } from './KubectlShellButton';
+
+vi.mock('@/react/hooks/useAnalytics', () => ({
+ useAnalytics: vi.fn().mockReturnValue({
+ trackEvent: vi.fn(),
+ }),
+}));
+
+vi.mock('@/portainer/helpers/pathHelper', () => ({
+ baseHref: vi.fn().mockReturnValue('/portainer'),
+}));
+
+const mockWindowOpen = vi.fn();
+const originalWindowOpen = window.open;
+
+beforeEach(() => {
+ window.open = mockWindowOpen;
+ mockWindowOpen.mockClear();
+});
+
+afterEach(() => {
+ window.open = originalWindowOpen;
+});
+
+function MockSidebarProvider({
+ children,
+ isOpen = true,
+}: PropsWithChildren<{ isOpen?: boolean }>) {
+ const state = useMemo(() => ({ isOpen, toggle: vi.fn() }), [isOpen]);
+
+ return
{children};
+}
+
+function renderComponent(environmentId = 1, isSidebarOpen = true) {
+ return render(
+
+
+
+ );
+}
+
+describe('KubectlShellButton', () => {
+ test('should render button with text when sidebar is open', () => {
+ renderComponent();
+
+ const button = screen.getByTestId('k8sSidebar-shellButton');
+ expect(button).toBeVisible();
+ expect(button).toHaveTextContent('kubectl shell');
+ });
+
+ test('should render button without text when sidebar is closed', () => {
+ renderComponent(1, false);
+
+ const button = screen.getByTestId('k8sSidebar-shellButton');
+ expect(button).toBeVisible();
+ expect(button).not.toHaveTextContent('kubectl shell');
+ expect(button).toHaveClass('!p-1');
+ });
+
+ test('should wrap button in tooltip when sidebar is closed', () => {
+ renderComponent(1, false);
+
+ // When sidebar is closed, the button is wrapped in a span with flex classes
+ const button = screen.getByTestId('k8sSidebar-shellButton');
+ const wrapperSpan = button.parentElement;
+ expect(wrapperSpan).toHaveClass('flex', 'w-full', 'justify-center');
+
+ // The button should have the !p-1 class when sidebar is closed
+ expect(button).toHaveClass('!p-1');
+ });
+
+ test('should open new window with correct URL when button is clicked', () => {
+ const environmentId = 5;
+ renderComponent(environmentId);
+
+ const button = screen.getByTestId('k8sSidebar-shellButton');
+ fireEvent.click(button);
+
+ expect(mockWindowOpen).toHaveBeenCalledTimes(1);
+ const [url, windowName, windowFeatures] = mockWindowOpen.mock.calls[0];
+
+ expect(url).toBe(
+ `${window.location.origin}/portainer#!/${environmentId}/kubernetes/kubectl-shell`
+ );
+ expect(windowName).toMatch(/^kubectl-shell-5-[a-f0-9-]+$/);
+ expect(windowFeatures).toBe('width=800,height=600');
+ });
+
+ test('should track analytics event when button is clicked', () => {
+ const mockTrackEvent = vi.fn();
+ vi.mocked(useAnalytics).mockReturnValue({ trackEvent: mockTrackEvent });
+
+ renderComponent();
+
+ const button = screen.getByRole('button');
+ fireEvent.click(button);
+
+ expect(mockTrackEvent).toHaveBeenCalledWith('kubernetes-kubectl-shell', {
+ category: 'kubernetes',
+ });
+ });
+
+ test('should generate unique window names for multiple clicks', () => {
+ renderComponent();
+
+ const button = screen.getByRole('button');
+
+ // Click multiple times
+ fireEvent.click(button);
+ fireEvent.click(button);
+
+ expect(mockWindowOpen).toHaveBeenCalledTimes(2);
+
+ const windowName1 = mockWindowOpen.mock.calls[0][1];
+ const windowName2 = mockWindowOpen.mock.calls[1][1];
+
+ expect(windowName1).not.toBe(windowName2);
+ });
+});
diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx b/app/react/sidebar/KubernetesSidebar/KubectlShellButton.tsx
similarity index 69%
rename from app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx
rename to app/react/sidebar/KubernetesSidebar/KubectlShellButton.tsx
index 0d08a03c8..1745c6a3e 100644
--- a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx
+++ b/app/react/sidebar/KubernetesSidebar/KubectlShellButton.tsx
@@ -1,32 +1,27 @@
-import { useState } from 'react';
-import { createPortal } from 'react-dom';
import { Terminal } from 'lucide-react';
import clsx from 'clsx';
+import { v4 as uuidv4 } from 'uuid';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useAnalytics } from '@/react/hooks/useAnalytics';
+import { baseHref } from '@/portainer/helpers/pathHelper';
import { Button } from '@@/buttons';
-import { useSidebarState } from '../../useSidebarState';
-import { SidebarTooltip } from '../../SidebarItem/SidebarTooltip';
-
-import { KubeCtlShell } from './KubectlShell';
+import { useSidebarState } from '../useSidebarState';
+import { SidebarTooltip } from '../SidebarItem/SidebarTooltip';
interface Props {
environmentId: EnvironmentId;
}
export function KubectlShellButton({ environmentId }: Props) {
const { isOpen: isSidebarOpen } = useSidebarState();
-
- const [open, setOpen] = useState(false);
const { trackEvent } = useAnalytics();
const button = (