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' && ( +
+ +
+ + +
+
-
- - - -
-
- -
-
Loading Terminal...
-
+ )} +
); - 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 = (