mirror of
https://github.com/portainer/portainer.git
synced 2025-07-29 18:29:44 +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
|
@ -31,8 +31,8 @@
|
||||||
<div
|
<div
|
||||||
id="page-wrapper"
|
id="page-wrapper"
|
||||||
ng-class="{
|
ng-class="{
|
||||||
open: isSidebarOpen() && ['portainer.auth', 'portainer.init.admin'].indexOf($state.current.name) === -1,
|
open: isSidebarOpen() && ['portainer.auth', 'portainer.init.admin', 'kubernetes.kubectlshell'].indexOf($state.current.name) === -1,
|
||||||
nopadding: ['portainer.auth', 'portainer.init.admin', 'portainer.logout'].indexOf($state.current.name) > -1 || applicationState.loading
|
nopadding: ['portainer.auth', 'portainer.init.admin', 'portainer.logout', 'kubernetes.kubectlshell'].indexOf($state.current.name) > -1 || applicationState.loading
|
||||||
}"
|
}"
|
||||||
ng-cloak
|
ng-cloak
|
||||||
>
|
>
|
||||||
|
|
|
@ -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 = [
|
const kubeTypes = [
|
||||||
PortainerEndpointTypes.KubernetesLocalEnvironment,
|
PortainerEndpointTypes.KubernetesLocalEnvironment,
|
||||||
PortainerEndpointTypes.AgentOnKubernetesEnvironment,
|
PortainerEndpointTypes.AgentOnKubernetesEnvironment,
|
||||||
|
@ -120,6 +127,11 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||||
EndpointProvider.clean();
|
EndpointProvider.clean();
|
||||||
Notifications.error('Failed loading environment', e);
|
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 });
|
$state.go('portainer.home', params, { reload: true, inherit: false });
|
||||||
return 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 = {
|
const dashboard = {
|
||||||
name: 'kubernetes.dashboard',
|
name: 'kubernetes.dashboard',
|
||||||
url: '/dashboard',
|
url: '/dashboard',
|
||||||
|
@ -657,6 +680,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||||
$stateRegistryProvider.register(deploy);
|
$stateRegistryProvider.register(deploy);
|
||||||
$stateRegistryProvider.register(node);
|
$stateRegistryProvider.register(node);
|
||||||
$stateRegistryProvider.register(nodeStats);
|
$stateRegistryProvider.register(nodeStats);
|
||||||
|
$stateRegistryProvider.register(kubectlShell);
|
||||||
$stateRegistryProvider.register(resourcePools);
|
$stateRegistryProvider.register(resourcePools);
|
||||||
$stateRegistryProvider.register(namespaceCreation);
|
$stateRegistryProvider.register(namespaceCreation);
|
||||||
$stateRegistryProvider.register(resourcePool);
|
$stateRegistryProvider.register(resourcePool);
|
||||||
|
|
|
@ -24,6 +24,7 @@ import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView'
|
||||||
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
|
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
|
||||||
import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
|
import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
|
||||||
import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView';
|
import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView';
|
||||||
|
import { KubectlShellView } from '@/react/kubernetes/cluster/KubectlShell/KubectlShellView';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.kubernetes.react.views', [])
|
.module('portainer.kubernetes.react.views', [])
|
||||||
|
@ -84,6 +85,10 @@ export const viewsModule = angular
|
||||||
'kubernetesHelmApplicationView',
|
'kubernetesHelmApplicationView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), [])
|
r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), [])
|
||||||
)
|
)
|
||||||
|
.component(
|
||||||
|
'kubectlShellView',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(KubectlShellView))), [])
|
||||||
|
)
|
||||||
.component(
|
.component(
|
||||||
'kubernetesClusterView',
|
'kubernetesClusterView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), [])
|
r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), [])
|
||||||
|
|
|
@ -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<Terminal>;
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loading Terminal...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates WebSocket connection with correct URL', () => {
|
||||||
|
render(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
expect(global.WebSocket).toHaveBeenCalledWith(
|
||||||
|
'ws://localhost:3000/portainer/api/websocket/kubernetes-shell?endpointId=1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets up terminal event handlers on mount', () => {
|
||||||
|
render(<KubectlShellView />);
|
||||||
|
|
||||||
|
expect(mockTerminalInstance.onData).toHaveBeenCalled();
|
||||||
|
expect(mockTerminalInstance.onKey).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds window resize listener on mount', () => {
|
||||||
|
render(<KubectlShellView />);
|
||||||
|
|
||||||
|
expect(window.addEventListener).toHaveBeenCalledWith(
|
||||||
|
'resize',
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends terminal data to WebSocket when terminal data event fires', () => {
|
||||||
|
render(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
// 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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
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(<KubectlShellView />);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(mockWebSocket.close).toHaveBeenCalled();
|
||||||
|
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||||||
|
expect(window.removeEventListener).toHaveBeenCalledWith(
|
||||||
|
'resize',
|
||||||
|
expect.any(Function)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,56 +1,39 @@
|
||||||
import { Terminal } from 'xterm';
|
import { Terminal } from 'xterm';
|
||||||
import { fit } from 'xterm/lib/addons/fit/fit';
|
import { fit } from 'xterm/lib/addons/fit/fit';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 { baseHref } from '@/portainer/helpers/pathHelper';
|
||||||
import {
|
import { terminalClose } from '@/portainer/services/terminal-window';
|
||||||
terminalClose,
|
|
||||||
terminalResize,
|
|
||||||
} from '@/portainer/services/terminal-window';
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { error as notifyError } from '@/portainer/services/notifications';
|
import { error as notifyError } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
import { Icon } from '@@/Icon';
|
import { Alert } from '@@/Alert';
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
import styles from './KubectlShell.module.css';
|
type Socket = WebSocket | null;
|
||||||
|
type ShellState = 'loading' | 'connected' | 'disconnected';
|
||||||
|
|
||||||
interface ShellState {
|
export function KubectlShellView() {
|
||||||
socket: WebSocket | null;
|
const environmentId = useEnvironmentId();
|
||||||
minimized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
environmentId: EnvironmentId;
|
|
||||||
onClose(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KubeCtlShell({ environmentId, onClose }: Props) {
|
|
||||||
const [terminal] = useState(new Terminal());
|
const [terminal] = useState(new Terminal());
|
||||||
|
|
||||||
const [shell, setShell] = useState<ShellState>({
|
const [socket, setSocket] = useState<Socket>(null);
|
||||||
socket: null,
|
const [shellState, setShellState] = useState<ShellState>('loading');
|
||||||
minimized: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { socket } = shell;
|
|
||||||
|
|
||||||
const terminalElem = useRef(null);
|
const terminalElem = useRef(null);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const closeTerminal = useCallback(() => {
|
||||||
terminalClose(); // only css trick
|
terminalClose(); // only css trick
|
||||||
socket?.close();
|
socket?.close();
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
onClose();
|
setShellState('disconnected');
|
||||||
}, [onClose, terminal, socket]);
|
}, [terminal, socket]);
|
||||||
|
|
||||||
const openTerminal = useCallback(() => {
|
const openTerminal = useCallback(() => {
|
||||||
if (!terminalElem.current) {
|
if (!terminalElem.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.open(terminalElem.current);
|
terminal.open(terminalElem.current);
|
||||||
terminal.setOption('cursorBlink', true);
|
terminal.setOption('cursorBlink', true);
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
|
@ -58,6 +41,11 @@ export function KubeCtlShell({ environmentId, onClose }: Props) {
|
||||||
terminal.writeln('#Run kubectl commands inside here');
|
terminal.writeln('#Run kubectl commands inside here');
|
||||||
terminal.writeln('#e.g. kubectl get all');
|
terminal.writeln('#e.g. kubectl get all');
|
||||||
terminal.writeln('');
|
terminal.writeln('');
|
||||||
|
setShellState('connected');
|
||||||
|
}, [terminal]);
|
||||||
|
|
||||||
|
const resizeTerminal = useCallback(() => {
|
||||||
|
fit(terminal);
|
||||||
}, [terminal]);
|
}, [terminal]);
|
||||||
|
|
||||||
// refresh socket listeners on socket updates
|
// refresh socket listeners on socket updates
|
||||||
|
@ -73,10 +61,10 @@ export function KubeCtlShell({ environmentId, onClose }: Props) {
|
||||||
terminal.writeUtf8(encoded);
|
terminal.writeUtf8(encoded);
|
||||||
}
|
}
|
||||||
function onClose() {
|
function onClose() {
|
||||||
handleClose();
|
closeTerminal();
|
||||||
}
|
}
|
||||||
function onError(e: Event) {
|
function onError(e: Event) {
|
||||||
handleClose();
|
closeTerminal();
|
||||||
if (socket?.readyState !== WebSocket.CLOSED) {
|
if (socket?.readyState !== WebSocket.CLOSED) {
|
||||||
notifyError(
|
notifyError(
|
||||||
'Failure',
|
'Failure',
|
||||||
|
@ -97,89 +85,63 @@ export function KubeCtlShell({ environmentId, onClose }: Props) {
|
||||||
socket.removeEventListener('close', onClose);
|
socket.removeEventListener('close', onClose);
|
||||||
socket.removeEventListener('error', onError);
|
socket.removeEventListener('error', onError);
|
||||||
};
|
};
|
||||||
}, [handleClose, openTerminal, socket, terminal]);
|
}, [closeTerminal, openTerminal, socket, terminal]);
|
||||||
|
|
||||||
// on component load/destroy
|
// on component load/destroy
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const socket = new WebSocket(buildUrl(environmentId));
|
const socket = new WebSocket(buildUrl(environmentId));
|
||||||
setShell((shell) => ({ ...shell, socket }));
|
setSocket(socket);
|
||||||
|
setShellState('loading');
|
||||||
|
|
||||||
terminal.onData((data) => socket.send(data));
|
terminal.onData((data) => socket.send(data));
|
||||||
terminal.onKey(({ domEvent }) => {
|
terminal.onKey(({ domEvent }) => {
|
||||||
if (domEvent.ctrlKey && domEvent.code === 'KeyD') {
|
if (domEvent.ctrlKey && domEvent.code === 'KeyD') {
|
||||||
close();
|
close();
|
||||||
|
setShellState('disconnected');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('resize', () => terminalResize());
|
window.addEventListener('resize', resizeTerminal);
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
socket.close();
|
socket.close();
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
window.removeEventListener('resize', terminalResize);
|
window.removeEventListener('resize', resizeTerminal);
|
||||||
}
|
}
|
||||||
|
|
||||||
return close;
|
return close;
|
||||||
}, [environmentId, terminal]);
|
}, [environmentId, terminal, resizeTerminal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.root, { [styles.minimized]: shell.minimized })}>
|
<div className="fixed bottom-0 left-0 right-0 top-0 z-[10000] bg-black text-white">
|
||||||
<div className={styles.header}>
|
{shellState === 'loading' && (
|
||||||
<div className={clsx(styles.title, 'vertical-center')}>
|
<div className="px-4 pt-2">Loading Terminal...</div>
|
||||||
<Icon icon={TerminalIcon} />
|
)}
|
||||||
kubectl shell
|
{shellState === 'disconnected' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<Alert color="info" title="Console disconnected">
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
data-cy="k8sShell-reloadButton"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.close()}
|
||||||
|
color="default"
|
||||||
|
data-cy="k8sShell-closeButton"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx(styles.actions, 'space-x-8')}>
|
)}
|
||||||
<Button
|
<div className="h-full" ref={terminalElem} />
|
||||||
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>
|
</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) {
|
function buildUrl(environmentId: EnvironmentId) {
|
||||||
const params = {
|
const params = {
|
||||||
endpointId: environmentId,
|
endpointId: environmentId,
|
|
@ -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 +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 { Terminal } from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { useAnalytics } from '@/react/hooks/useAnalytics';
|
import { useAnalytics } from '@/react/hooks/useAnalytics';
|
||||||
|
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||||
|
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
import { useSidebarState } from '../../useSidebarState';
|
import { useSidebarState } from '../useSidebarState';
|
||||||
import { SidebarTooltip } from '../../SidebarItem/SidebarTooltip';
|
import { SidebarTooltip } from '../SidebarItem/SidebarTooltip';
|
||||||
|
|
||||||
import { KubeCtlShell } from './KubectlShell';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
environmentId: EnvironmentId;
|
environmentId: EnvironmentId;
|
||||||
}
|
}
|
||||||
export function KubectlShellButton({ environmentId }: Props) {
|
export function KubectlShellButton({ environmentId }: Props) {
|
||||||
const { isOpen: isSidebarOpen } = useSidebarState();
|
const { isOpen: isSidebarOpen } = useSidebarState();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
|
|
||||||
const button = (
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
size="small"
|
size="small"
|
||||||
disabled={open}
|
|
||||||
data-cy="k8sSidebar-shellButton"
|
data-cy="k8sSidebar-shellButton"
|
||||||
onClick={() => handleOpen()}
|
onClick={() => handleOpen()}
|
||||||
className={clsx('sidebar', !isSidebarOpen && '!p-1')}
|
className={clsx('sidebar', !isSidebarOpen && '!p-1')}
|
||||||
|
@ -48,19 +43,17 @@ export function KubectlShellButton({ environmentId }: Props) {
|
||||||
</SidebarTooltip>
|
</SidebarTooltip>
|
||||||
)}
|
)}
|
||||||
{isSidebarOpen && button}
|
{isSidebarOpen && button}
|
||||||
{open &&
|
|
||||||
createPortal(
|
|
||||||
<KubeCtlShell
|
|
||||||
environmentId={environmentId}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleOpen() {
|
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' });
|
trackEvent('kubernetes-kubectl-shell', { category: 'kubernetes' });
|
||||||
}
|
}
|
|
@ -17,7 +17,7 @@ import { SidebarItem } from '../SidebarItem';
|
||||||
import { VolumesLink } from '../items/VolumesLink';
|
import { VolumesLink } from '../items/VolumesLink';
|
||||||
import { SidebarParent } from '../SidebarItem/SidebarParent';
|
import { SidebarParent } from '../SidebarItem/SidebarParent';
|
||||||
|
|
||||||
import { KubectlShellButton } from './KubectlShell';
|
import { KubectlShellButton } from './KubectlShellButton';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
environmentId: EnvironmentId;
|
environmentId: EnvironmentId;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue