mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 05:45:22 +02:00
feat(app/kubernetes): Popout kubectl shell into new window [r8s-307] (#922)
This commit is contained in:
parent
e7d97d7a2b
commit
7bcb37c761
10 changed files with 677 additions and 158 deletions
|
@ -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;
|
||||
}
|
|
@ -1,200 +0,0 @@
|
|||
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 { baseHref } from '@/portainer/helpers/pathHelper';
|
||||
import {
|
||||
terminalClose,
|
||||
terminalResize,
|
||||
} 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 { Button } from '@@/buttons';
|
||||
|
||||
import styles from './KubectlShell.module.css';
|
||||
|
||||
interface ShellState {
|
||||
socket: WebSocket | null;
|
||||
minimized: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
environmentId: EnvironmentId;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function KubeCtlShell({ environmentId, onClose }: Props) {
|
||||
const [terminal] = useState(new Terminal());
|
||||
|
||||
const [shell, setShell] = useState<ShellState>({
|
||||
socket: null,
|
||||
minimized: false,
|
||||
});
|
||||
|
||||
const { socket } = shell;
|
||||
|
||||
const terminalElem = useRef(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
terminalClose(); // only css trick
|
||||
socket?.close();
|
||||
terminal.dispose();
|
||||
onClose();
|
||||
}, [onClose, 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('');
|
||||
}, [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() {
|
||||
handleClose();
|
||||
}
|
||||
function onError(e: Event) {
|
||||
handleClose();
|
||||
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);
|
||||
};
|
||||
}, [handleClose, openTerminal, socket, terminal]);
|
||||
|
||||
// on component load/destroy
|
||||
useEffect(() => {
|
||||
const socket = new WebSocket(buildUrl(environmentId));
|
||||
setShell((shell) => ({ ...shell, socket }));
|
||||
|
||||
terminal.onData((data) => socket.send(data));
|
||||
terminal.onKey(({ domEvent }) => {
|
||||
if (domEvent.ctrlKey && domEvent.code === 'KeyD') {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => terminalResize());
|
||||
|
||||
function close() {
|
||||
socket.close();
|
||||
terminal.dispose();
|
||||
window.removeEventListener('resize', terminalResize);
|
||||
}
|
||||
|
||||
return close;
|
||||
}, [environmentId, terminal]);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.root, { [styles.minimized]: shell.minimized })}>
|
||||
<div className={styles.header}>
|
||||
<div className={clsx(styles.title, 'vertical-center')}>
|
||||
<Icon icon={TerminalIcon} />
|
||||
kubectl shell
|
||||
</div>
|
||||
<div className={clsx(styles.actions, 'space-x-8')}>
|
||||
<Button
|
||||
color="link"
|
||||
onClick={clearScreen}
|
||||
data-cy="k8sShell-refreshButton"
|
||||
>
|
||||
<Icon icon={RotateCw} size="md" />
|
||||
</Button>
|
||||
<Button
|
||||
color="link"
|
||||
onClick={toggleMinimize}
|
||||
data-cy={shell.minimized ? 'k8sShell-restore' : 'k8sShell-minimise'}
|
||||
>
|
||||
<Icon
|
||||
icon={shell.minimized ? 'maximize-2' : 'minimize-2'}
|
||||
size="md"
|
||||
data-cy={
|
||||
shell.minimized ? 'k8sShell-restore' : 'k8sShell-minimise'
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
color="link"
|
||||
onClick={handleClose}
|
||||
data-cy="k8sShell-closeButton"
|
||||
>
|
||||
<Icon icon={X} size="md" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.terminalContainer} ref={terminalElem}>
|
||||
<div className={styles.loadingMessage}>Loading Terminal...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
const wsProtocol =
|
||||
window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const path = `${baseHref()}api/websocket/kubernetes-shell`;
|
||||
const base = path.startsWith('http')
|
||||
? path.replace(/^https?:\/\//i, '')
|
||||
: window.location.host + path;
|
||||
|
||||
const queryParams = Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('&');
|
||||
return `${wsProtocol}${base}?${queryParams}`;
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { KubectlShellButton } from './KubectlShellButton';
|
127
app/react/sidebar/KubernetesSidebar/KubectlShellButton.test.tsx
Normal file
127
app/react/sidebar/KubernetesSidebar/KubectlShellButton.test.tsx
Normal file
|
@ -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 <Context.Provider value={state}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
function renderComponent(environmentId = 1, isSidebarOpen = true) {
|
||||
return render(
|
||||
<MockSidebarProvider isOpen={isSidebarOpen}>
|
||||
<KubectlShellButton environmentId={environmentId} />
|
||||
</MockSidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -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 = (
|
||||
<Button
|
||||
color="primary"
|
||||
size="small"
|
||||
disabled={open}
|
||||
data-cy="k8sSidebar-shellButton"
|
||||
onClick={() => handleOpen()}
|
||||
className={clsx('sidebar', !isSidebarOpen && '!p-1')}
|
||||
|
@ -48,19 +43,17 @@ export function KubectlShellButton({ environmentId }: Props) {
|
|||
</SidebarTooltip>
|
||||
)}
|
||||
{isSidebarOpen && button}
|
||||
{open &&
|
||||
createPortal(
|
||||
<KubeCtlShell
|
||||
environmentId={environmentId}
|
||||
onClose={() => setOpen(false)}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleOpen() {
|
||||
setOpen(true);
|
||||
const url = window.location.origin + baseHref();
|
||||
window.open(
|
||||
`${url}#!/${environmentId}/kubernetes/kubectl-shell`,
|
||||
// give the window a unique name so that more than one can be opened
|
||||
`kubectl-shell-${environmentId}-${uuidv4()}`,
|
||||
'width=800,height=600'
|
||||
);
|
||||
|
||||
trackEvent('kubernetes-kubectl-shell', { category: 'kubernetes' });
|
||||
}
|
|
@ -17,7 +17,7 @@ import { SidebarItem } from '../SidebarItem';
|
|||
import { VolumesLink } from '../items/VolumesLink';
|
||||
import { SidebarParent } from '../SidebarItem/SidebarParent';
|
||||
|
||||
import { KubectlShellButton } from './KubectlShell';
|
||||
import { KubectlShellButton } from './KubectlShellButton';
|
||||
|
||||
interface Props {
|
||||
environmentId: EnvironmentId;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue