mirror of
https://github.com/podman-desktop/podman-desktop
synced 2026-05-24 10:18:53 +00:00
feat: open terminal in the running container
Signed-off-by: Vladyslav Zhukovskyi <vzhukovs@redhat.com>
This commit is contained in:
parent
23ebd88a3e
commit
66734cee5c
14 changed files with 1053 additions and 0 deletions
|
|
@ -1921,6 +1921,53 @@ export class PluginSystem {
|
|||
},
|
||||
);
|
||||
|
||||
const kubernetesExecCallbackMap = new Map<
|
||||
number,
|
||||
{ onStdIn: (data: string) => void; onResize: (columns: number, rows: number) => void }
|
||||
>();
|
||||
this.ipcHandle(
|
||||
'kubernetes-client:execIntoContainer',
|
||||
async (_listener, podName: string, containerName: string, onDataId: number): Promise<number> => {
|
||||
const execInvocation = await kubernetesClient.execIntoContainer(
|
||||
podName,
|
||||
containerName,
|
||||
(stdOut: Buffer) => {
|
||||
this.getWebContentsSender().send('kubernetes-client:execIntoContainer-onData', onDataId, stdOut);
|
||||
},
|
||||
(stdErr: Buffer) => {
|
||||
this.getWebContentsSender().send('kubernetes-client:execIntoContainer-onError', onDataId, stdErr);
|
||||
},
|
||||
() => {
|
||||
this.getWebContentsSender().send('kubernetes-client:execIntoContainer-onClose', onDataId);
|
||||
kubernetesExecCallbackMap.delete(onDataId);
|
||||
},
|
||||
);
|
||||
kubernetesExecCallbackMap.set(onDataId, execInvocation);
|
||||
|
||||
return onDataId;
|
||||
},
|
||||
);
|
||||
|
||||
this.ipcHandle(
|
||||
'kubernetes-client:execIntoContainerSend',
|
||||
async (_listener, onDataId: number, content: string): Promise<void> => {
|
||||
const callback = kubernetesExecCallbackMap.get(onDataId);
|
||||
if (callback) {
|
||||
callback.onStdIn(content);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.ipcHandle(
|
||||
'kubernetes-client:execIntoContainerResize',
|
||||
async (_listener, onDataId: number, width: number, height: number): Promise<void> => {
|
||||
const callback = kubernetesExecCallbackMap.get(onDataId);
|
||||
if (callback) {
|
||||
callback.onResize(width, height);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.ipcHandle('feedback:send', async (_listener, feedbackProperties: unknown): Promise<void> => {
|
||||
return telemetry.sendFeedback(feedbackProperties);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,19 +19,23 @@
|
|||
import * as fs from 'node:fs';
|
||||
import { IncomingMessage } from 'node:http';
|
||||
import { Socket } from 'node:net';
|
||||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
import {
|
||||
Exec,
|
||||
KubeConfig,
|
||||
type KubernetesObject,
|
||||
type V1Deployment,
|
||||
type V1Ingress,
|
||||
type V1Service,
|
||||
type V1Status,
|
||||
type Watch,
|
||||
} from '@kubernetes/client-node';
|
||||
import * as clientNode from '@kubernetes/client-node';
|
||||
import type { FileSystemWatcher } from '@podman-desktop/api';
|
||||
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { ResizableTerminalWriter } from '/@/plugin/kubernetes-exec-transmitter.js';
|
||||
import type { Telemetry } from '/@/plugin/telemetry/telemetry.js';
|
||||
|
||||
import type { ApiSenderType } from './api.js';
|
||||
|
|
@ -101,6 +105,7 @@ const apiSender: ApiSenderType = {
|
|||
receive: vi.fn(),
|
||||
};
|
||||
|
||||
const execMock = vi.fn();
|
||||
beforeAll(() => {
|
||||
vi.mock('@kubernetes/client-node', async () => {
|
||||
return {
|
||||
|
|
@ -119,6 +124,7 @@ beforeAll(() => {
|
|||
this.statusCode = statusCode;
|
||||
}
|
||||
},
|
||||
Exec: vi.fn(),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
|
@ -129,6 +135,7 @@ beforeEach(() => {
|
|||
KubeConfig.prototype.makeApiClient = makeApiClientMock;
|
||||
KubeConfig.prototype.getContextObject = getContextObjectMock;
|
||||
KubeConfig.prototype.currentContext = 'context';
|
||||
Exec.prototype.exec = execMock;
|
||||
});
|
||||
|
||||
test('Create Kubernetes resources with empty should return ok', async () => {
|
||||
|
|
@ -1190,3 +1197,107 @@ test('setupWatcher sends kubernetes-context-update when kubeconfig file is delet
|
|||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(apiSenderSendMock).toHaveBeenCalledWith('kubernetes-context-update');
|
||||
});
|
||||
|
||||
test('Test should exec into container ', async () => {
|
||||
const client = createTestClient('default');
|
||||
makeApiClientMock.mockReturnValue({
|
||||
getCode: () => Promise.resolve({ body: { gitVersion: 'v1.20.0' } }),
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
const onStdOutFn = (data: Buffer): void => {
|
||||
stdout += data.toString();
|
||||
};
|
||||
|
||||
let stderr = '';
|
||||
const onStdErrFn = (data: Buffer): void => {
|
||||
stderr += data.toString();
|
||||
};
|
||||
|
||||
const onCloseFn = vi.fn();
|
||||
|
||||
execMock.mockImplementation(
|
||||
(
|
||||
namespace: string,
|
||||
podName: string,
|
||||
containerName: string,
|
||||
command: string | string[],
|
||||
stdout: Writable | null,
|
||||
stderr: Writable | null,
|
||||
stdin: Readable | null,
|
||||
tty: boolean,
|
||||
_?: (status: V1Status) => void,
|
||||
) => {
|
||||
expect(namespace).toBe('default');
|
||||
expect(podName).toBe('test-pod');
|
||||
expect(containerName).toBe('test-container');
|
||||
expect(tty).toBeTruthy();
|
||||
expect(command).toEqual(['/bin/sh', '-c', 'if command -v bash >/dev/null 2>&1; then bash; else sh; fi']);
|
||||
|
||||
if (stdout) {
|
||||
stdout.write('stdOut output');
|
||||
|
||||
stdout.on('resize', () => {
|
||||
expect(stdout).instanceOf(ResizableTerminalWriter);
|
||||
const { width, height } = (stdout as ResizableTerminalWriter).getDimension();
|
||||
expect(width).toBe(1);
|
||||
expect(height).toBe(1);
|
||||
});
|
||||
}
|
||||
if (stderr) {
|
||||
stderr.write('stdErr output');
|
||||
}
|
||||
|
||||
if (stdin) {
|
||||
stdin.on('data', chunk => expect(chunk.toString()).toEqual('stdIn input'));
|
||||
}
|
||||
|
||||
return { on: vi.fn() };
|
||||
},
|
||||
);
|
||||
|
||||
const execResp = await client.execIntoContainer('test-pod', 'test-container', onStdOutFn, onStdErrFn, onCloseFn);
|
||||
|
||||
expect(stdout).toBe('stdOut output');
|
||||
expect(stderr).toBe('stdErr output');
|
||||
|
||||
execResp.onStdIn('stdIn input');
|
||||
execResp.onResize(1, 1);
|
||||
});
|
||||
|
||||
test('Test should throw an exception during exec command if resize parameters are wrong', async () => {
|
||||
const client = createTestClient('default');
|
||||
makeApiClientMock.mockReturnValue({
|
||||
getCode: () => Promise.resolve({ body: { gitVersion: 'v1.20.0' } }),
|
||||
});
|
||||
|
||||
const execResp = await client.execIntoContainer(
|
||||
'test-pod',
|
||||
'test-container',
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(() => execResp.onResize(-1, -1)).toThrow('resizing must be done using positive cols and rows');
|
||||
expect(() => execResp.onResize(0, 0)).toThrow('resizing must be done using positive cols and rows');
|
||||
expect(() => execResp.onResize(Number.NaN, Number.NaN)).toThrow('resizing must be done using positive cols and rows');
|
||||
expect(() => execResp.onResize(Infinity, Infinity)).toThrow('resizing must be done using positive cols and rows');
|
||||
});
|
||||
|
||||
test('Test should throw an exception during exec command if internal kube method fails', async () => {
|
||||
const client = createTestClient('default');
|
||||
makeApiClientMock.mockReturnValue({
|
||||
getCode: () => Promise.reject(new Error('K8sError')),
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.execIntoContainer(
|
||||
'test-pod',
|
||||
'test-container',
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
),
|
||||
).rejects.toThrowError('not active connection');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,12 +36,14 @@ import type {
|
|||
V1Pod,
|
||||
V1PodList,
|
||||
V1Service,
|
||||
V1Status,
|
||||
} from '@kubernetes/client-node';
|
||||
import {
|
||||
ApisApi,
|
||||
AppsV1Api,
|
||||
CoreV1Api,
|
||||
CustomObjectsApi,
|
||||
Exec,
|
||||
HttpError,
|
||||
KubeConfig,
|
||||
KubernetesObjectApi,
|
||||
|
|
@ -65,6 +67,7 @@ import type { FilesystemMonitoring } from './filesystem-monitoring.js';
|
|||
import type { KubeContext } from './kubernetes-context.js';
|
||||
import type { ContextGeneralState, ResourceName } from './kubernetes-context-state.js';
|
||||
import { ContextsManager } from './kubernetes-context-state.js';
|
||||
import { BufferedStreamWriter, ResizableTerminalWriter, StringLineReader } from './kubernetes-exec-transmitter.js';
|
||||
import { Uri } from './types/uri.js';
|
||||
|
||||
interface KubernetesObjectWithKind extends KubernetesObject {
|
||||
|
|
@ -1240,4 +1243,70 @@ export class KubernetesClient {
|
|||
public dispose(): void {
|
||||
this.contextsState.dispose();
|
||||
}
|
||||
|
||||
async execIntoContainer(
|
||||
podName: string,
|
||||
containerName: string,
|
||||
onStdOut: (data: Buffer) => void,
|
||||
onStdErr: (data: Buffer) => void,
|
||||
onClose: () => void,
|
||||
): Promise<{ onStdIn: (data: string) => void; onResize: (columns: number, rows: number) => void }> {
|
||||
let telemetryOptions = {};
|
||||
try {
|
||||
const ns = this.getCurrentNamespace();
|
||||
const connected = await this.checkConnection();
|
||||
if (!ns) {
|
||||
throw new Error('no active namespace');
|
||||
}
|
||||
if (!connected) {
|
||||
throw new Error('not active connection');
|
||||
}
|
||||
|
||||
const stdout = new ResizableTerminalWriter(new BufferedStreamWriter(onStdOut));
|
||||
const stderr = new ResizableTerminalWriter(new BufferedStreamWriter(onStdErr));
|
||||
const stdin = new StringLineReader();
|
||||
|
||||
const exec = new Exec(this.kubeConfig);
|
||||
const conn = await exec.exec(
|
||||
ns,
|
||||
podName,
|
||||
containerName,
|
||||
['/bin/sh', '-c', 'if command -v bash >/dev/null 2>&1; then bash; else sh; fi'],
|
||||
stdout,
|
||||
stderr,
|
||||
stdin,
|
||||
true,
|
||||
(_: V1Status) => {
|
||||
// need to think, maybe it would be better to pass exit code to the client, but on the other hand
|
||||
// if connection is idle for 15 minutes, websocket connection closes automatically and this handler
|
||||
// does not call. also need to separate SIGTERM signal (143) and normally exit signals to be able to
|
||||
// proper reconnect client terminal. at this moment we ignore status and rely on websocket close event
|
||||
},
|
||||
);
|
||||
|
||||
//need to handle websocket idling, which causes the connection close which is not passed to the execution status
|
||||
//approx time for idling before closing socket is 15 minutes. code and reason are always undefined here.
|
||||
conn.on('close', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
return {
|
||||
onStdIn: (data: string): void => {
|
||||
stdin.readLine(data);
|
||||
},
|
||||
onResize: (columns: number, rows: number): void => {
|
||||
if (columns <= 0 || rows <= 0 || isNaN(columns) || isNaN(rows) || columns === Infinity || rows === Infinity) {
|
||||
throw new Error('resizing must be done using positive cols and rows');
|
||||
}
|
||||
|
||||
(stdout as ResizableTerminalWriter).resize({ width: columns, height: rows });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
telemetryOptions = { error: error };
|
||||
throw this.wrapK8sClientError(error);
|
||||
} finally {
|
||||
this.telemetry.track('kubernetesExecIntoContainer', telemetryOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
packages/main/src/plugin/kubernetes-exec-transmitter.spec.ts
Normal file
65
packages/main/src/plugin/kubernetes-exec-transmitter.spec.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**********************************************************************
|
||||
* Copyright (C) 2024 Red Hat, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
***********************************************************************/
|
||||
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { TerminalSize } from '/@/plugin/kubernetes-exec-transmitter.js';
|
||||
import {
|
||||
BufferedStreamWriter,
|
||||
DEFAULT_COLUMNS,
|
||||
DEFAULT_ROWS,
|
||||
ResizableTerminalWriter,
|
||||
StringLineReader,
|
||||
} from '/@/plugin/kubernetes-exec-transmitter.js';
|
||||
|
||||
test('Test should verify string line reader', () => {
|
||||
const reader = new StringLineReader();
|
||||
|
||||
reader.on('data', chunk => {
|
||||
expect(chunk.toString()).toEqual('foo');
|
||||
});
|
||||
|
||||
reader.push('foo');
|
||||
});
|
||||
|
||||
test('Test should verify buffered stream writer', () => {
|
||||
const writer = new BufferedStreamWriter((data: Buffer) => {
|
||||
expect(data.toString()).toEqual('foo');
|
||||
});
|
||||
|
||||
writer.write(Buffer.from('foo'));
|
||||
});
|
||||
|
||||
test('Test should verify resizable terminal writer', () => {
|
||||
const writer = new ResizableTerminalWriter(
|
||||
new BufferedStreamWriter((data: Buffer) => {
|
||||
expect(data.toString()).toEqual('foo');
|
||||
}),
|
||||
);
|
||||
|
||||
writer.on('resize', () => {
|
||||
const dimension = writer.getDimension();
|
||||
expect(dimension).toEqual({ width: 1, height: 1 } as TerminalSize);
|
||||
});
|
||||
|
||||
writer.write(Buffer.from('foo'));
|
||||
|
||||
expect(writer.getDimension()).toEqual({ width: DEFAULT_COLUMNS, height: DEFAULT_ROWS } as TerminalSize);
|
||||
writer.resize({ width: 1, height: 1 } as TerminalSize);
|
||||
expect(writer.getDimension()).toEqual({ width: 1, height: 1 } as TerminalSize);
|
||||
});
|
||||
116
packages/main/src/plugin/kubernetes-exec-transmitter.ts
Normal file
116
packages/main/src/plugin/kubernetes-exec-transmitter.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**********************************************************************
|
||||
* Copyright (C) 2024 Red Hat, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
***********************************************************************/
|
||||
|
||||
import type { WritableOptions } from 'node:stream';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
|
||||
export const DEFAULT_COLUMNS: number = 80;
|
||||
export const DEFAULT_ROWS: number = 60;
|
||||
|
||||
export interface TerminalSize {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export class ExecStreamWriter extends Writable {
|
||||
protected transmitter: Writable;
|
||||
|
||||
constructor(transmitter: Writable, options?: WritableOptions) {
|
||||
super(options);
|
||||
this.transmitter = transmitter;
|
||||
}
|
||||
|
||||
_write(chunk: unknown, encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
||||
this.transmitter._write(chunk, encoding, callback);
|
||||
}
|
||||
}
|
||||
|
||||
export class ResizableTerminalWriter extends ExecStreamWriter {
|
||||
protected columns: number;
|
||||
protected rows: number;
|
||||
|
||||
constructor(
|
||||
transmitter: Writable,
|
||||
terminalSize: TerminalSize = { width: DEFAULT_COLUMNS, height: DEFAULT_ROWS },
|
||||
options?: WritableOptions,
|
||||
) {
|
||||
super(transmitter, options);
|
||||
this.columns = terminalSize.width;
|
||||
this.rows = terminalSize.height;
|
||||
}
|
||||
|
||||
_write(chunk: unknown, encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
||||
super._write(chunk, encoding, callback);
|
||||
}
|
||||
|
||||
resize(terminalSize: TerminalSize): void {
|
||||
this.columns = terminalSize.width;
|
||||
this.rows = terminalSize.height;
|
||||
|
||||
this.emit('resize');
|
||||
}
|
||||
|
||||
getDimension(): TerminalSize {
|
||||
return {
|
||||
width: this.columns,
|
||||
height: this.rows,
|
||||
} as TerminalSize;
|
||||
}
|
||||
}
|
||||
|
||||
export class BufferedStreamWriter extends Writable {
|
||||
private readonly transmitFn: (data: Buffer) => void;
|
||||
|
||||
constructor(transmitFn: (data: Buffer) => void, options?: WritableOptions) {
|
||||
super(options);
|
||||
this.transmitFn = transmitFn;
|
||||
}
|
||||
|
||||
_write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
|
||||
this.transmitFn(chunk);
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
export class StringLineReader extends Readable {
|
||||
private dataQueue: string[] = [];
|
||||
private isReading: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
readLine(data: string): void {
|
||||
this.dataQueue.push(data);
|
||||
if (!this.isReading) {
|
||||
this.read();
|
||||
}
|
||||
}
|
||||
|
||||
_read(): void {
|
||||
this.isReading = true;
|
||||
const data = this.dataQueue.shift();
|
||||
|
||||
if (data) {
|
||||
this.push(data);
|
||||
} else {
|
||||
this.isReading = false;
|
||||
this.push(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1745,6 +1745,55 @@ export function initExposure(): void {
|
|||
},
|
||||
);
|
||||
|
||||
// callbacks for shellInContainer
|
||||
let kubernetesCallbackId = 0;
|
||||
const kubernetesCallbackMap = new Map<
|
||||
number,
|
||||
{ onStdOut: (data: Buffer) => void; onStdErr: (data: Buffer) => void; onClose: () => void }
|
||||
>();
|
||||
contextBridge.exposeInMainWorld(
|
||||
'kubernetesExec',
|
||||
async (
|
||||
podName: string,
|
||||
containerName: string,
|
||||
onStdOut: (data: Buffer) => void,
|
||||
onStdErr: (data: Buffer) => void,
|
||||
onClose: () => void,
|
||||
): Promise<number> => {
|
||||
kubernetesCallbackId++;
|
||||
kubernetesCallbackMap.set(kubernetesCallbackId, { onStdOut, onStdErr, onClose });
|
||||
return ipcInvoke('kubernetes-client:execIntoContainer', podName, containerName, kubernetesCallbackId);
|
||||
},
|
||||
);
|
||||
|
||||
contextBridge.exposeInMainWorld('kubernetesExecSend', async (dataId: number, content: string): Promise<void> => {
|
||||
return ipcInvoke('kubernetes-client:execIntoContainerSend', dataId, content);
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('kubernetesExecResize', async (dataId: number, width: number, height: number) => {
|
||||
return ipcInvoke('kubernetes-client:execIntoContainerResize', dataId, width, height);
|
||||
});
|
||||
|
||||
ipcRenderer.on('kubernetes-client:execIntoContainer-onData', (_, kubernetesCallbackId: number, data: Buffer) => {
|
||||
const callback = kubernetesCallbackMap.get(kubernetesCallbackId);
|
||||
if (callback) {
|
||||
callback.onStdOut(data);
|
||||
}
|
||||
});
|
||||
ipcRenderer.on('kubernetes-client:execIntoContainer-onError', (_, kubernetesCallbackId: number, data: Buffer) => {
|
||||
const callback = kubernetesCallbackMap.get(kubernetesCallbackId);
|
||||
if (callback) {
|
||||
callback.onStdErr(data);
|
||||
}
|
||||
});
|
||||
ipcRenderer.on('kubernetes-client:execIntoContainer-onClose', (_, kubernetesCallbackId: number) => {
|
||||
const callback = kubernetesCallbackMap.get(kubernetesCallbackId);
|
||||
if (callback) {
|
||||
callback.onClose();
|
||||
onDataCallbacksShellInContainer.delete(kubernetesCallbackId);
|
||||
}
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld(
|
||||
'kubernetesApplyResourcesFromFile',
|
||||
async (context: string, file: string, namespace?: string): Promise<KubernetesObject[]> => {
|
||||
|
|
|
|||
79
packages/renderer/src/lib/pod/KubernetesTerminal.spec.ts
Normal file
79
packages/renderer/src/lib/pod/KubernetesTerminal.spec.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**********************************************************************
|
||||
* Copyright (C) 2024 Red Hat, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
***********************************************************************/
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import { render, waitFor } from '@testing-library/svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { beforeAll, expect, test, vi } from 'vitest';
|
||||
|
||||
import KubernetesTerminal from '/@/lib/pod/KubernetesTerminal.svelte';
|
||||
import { terminalStates } from '/@/stores/kubernetes-terminal-state-store';
|
||||
|
||||
const getConfigurationValueMock = vi.fn();
|
||||
const kubernetesExecMock = vi.fn();
|
||||
const kubernetesExecResizeMock = vi.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
(window as any).getConfigurationValue = getConfigurationValueMock;
|
||||
(window as any).kubernetesExec = kubernetesExecMock;
|
||||
(window as any).kubernetesExecResize = kubernetesExecResizeMock;
|
||||
|
||||
(window as any).matchMedia = vi.fn().mockReturnValue({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('Test should render the terminal and being able to reconnect', async () => {
|
||||
let onStdOutCallback: (data: Buffer) => void = () => {};
|
||||
const sendCallbackId = 1;
|
||||
kubernetesExecMock.mockImplementation(
|
||||
(
|
||||
_podName: string,
|
||||
_containerName: string,
|
||||
onStdOut: (data: Buffer) => void,
|
||||
_onStdErr: (data: Buffer) => void,
|
||||
_onClose: () => void,
|
||||
) => {
|
||||
onStdOutCallback = onStdOut;
|
||||
return sendCallbackId;
|
||||
},
|
||||
);
|
||||
|
||||
const renderObject = render(KubernetesTerminal, { podName: 'podName', containerName: 'containerName' });
|
||||
await waitFor(() => expect(kubernetesExecMock).toHaveBeenCalled());
|
||||
|
||||
onStdOutCallback(Buffer.from('hello\nworld'));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
const terminalLinesLiveRegion = renderObject.container.querySelector('div[aria-live="assertive"]');
|
||||
expect(terminalLinesLiveRegion).toHaveTextContent('hello world');
|
||||
|
||||
const terminals = get(terminalStates);
|
||||
expect(terminals.size).toBe(0);
|
||||
|
||||
renderObject.component.$destroy();
|
||||
const terminalsAfterDestroy = get(terminalStates);
|
||||
expect(terminalsAfterDestroy.size).toBe(1);
|
||||
|
||||
render(KubernetesTerminal, { podName: 'podName', containerName: 'containerName' });
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
expect(kubernetesExecMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
156
packages/renderer/src/lib/pod/KubernetesTerminal.svelte
Normal file
156
packages/renderer/src/lib/pod/KubernetesTerminal.svelte
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { router } from 'tinro';
|
||||
import { type IDisposable, Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
|
||||
import { getPanelDetailColor } from '/@/lib/color/color';
|
||||
import { terminalStates } from '/@/stores/kubernetes-terminal-state-store';
|
||||
|
||||
import { TerminalSettings } from '../../../../main/src/plugin/terminal-settings';
|
||||
|
||||
export let podName: string;
|
||||
export let containerName: string;
|
||||
|
||||
export let terminalXtermDiv: HTMLElement = document.createElement('div');
|
||||
let curRouterPath: string;
|
||||
|
||||
interface State {
|
||||
terminal: Terminal;
|
||||
id: number;
|
||||
}
|
||||
|
||||
let shellTerminal: Terminal;
|
||||
let screenReaderMode = true;
|
||||
|
||||
let id: number | undefined;
|
||||
let onDataDisposable: IDisposable | undefined;
|
||||
|
||||
router.subscribe(route => {
|
||||
curRouterPath = route.path;
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const savedState = getSavedTerminalState(podName, containerName);
|
||||
|
||||
if (savedState) {
|
||||
shellTerminal = savedState.terminal;
|
||||
id = savedState.id;
|
||||
removeAllChildren(terminalXtermDiv);
|
||||
shellTerminal.open(terminalXtermDiv);
|
||||
} else {
|
||||
await initializeNewTerminal(terminalXtermDiv);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
saveTerminalState(podName, containerName, { terminal: shellTerminal, id: id } as State);
|
||||
});
|
||||
|
||||
function removeAllChildren(element: HTMLElement): void {
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
window
|
||||
.kubernetesExec(
|
||||
podName,
|
||||
containerName,
|
||||
(data: Buffer) => {
|
||||
shellTerminal.write(data);
|
||||
},
|
||||
(data: Buffer) => {
|
||||
shellTerminal.write(data);
|
||||
},
|
||||
reconnect,
|
||||
)
|
||||
.then(execId => {
|
||||
id = execId;
|
||||
|
||||
shellTerminal.clear();
|
||||
onDataDisposable?.dispose();
|
||||
onDataDisposable = shellTerminal.onData(data => {
|
||||
window.kubernetesExecSend(id!, data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function initializeNewTerminal(container: HTMLElement) {
|
||||
if (!terminalXtermDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fontSize = await window.getConfigurationValue<number>(
|
||||
TerminalSettings.SectionName + '.' + TerminalSettings.FontSize,
|
||||
);
|
||||
const lineHeight = await window.getConfigurationValue<number>(
|
||||
TerminalSettings.SectionName + '.' + TerminalSettings.LineHeight,
|
||||
);
|
||||
|
||||
shellTerminal = new Terminal({
|
||||
fontSize,
|
||||
lineHeight,
|
||||
screenReaderMode,
|
||||
theme: {
|
||||
background: getPanelDetailColor(),
|
||||
},
|
||||
});
|
||||
|
||||
id = await window.kubernetesExec(
|
||||
podName,
|
||||
containerName,
|
||||
(data: Buffer) => {
|
||||
shellTerminal.write(data);
|
||||
},
|
||||
(data: Buffer) => {
|
||||
shellTerminal.write(data);
|
||||
},
|
||||
reconnect,
|
||||
);
|
||||
|
||||
onDataDisposable?.dispose();
|
||||
onDataDisposable = shellTerminal.onData(data => {
|
||||
window.kubernetesExecSend(id!, data);
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
shellTerminal.loadAddon(fitAddon);
|
||||
removeAllChildren(container);
|
||||
shellTerminal.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const resizeAsync = async () => {
|
||||
//resize all opened terminals
|
||||
if (curRouterPath.endsWith('/k8s-terminal')) {
|
||||
fitAddon.fit();
|
||||
if (id) {
|
||||
await window.kubernetesExecResize(id, shellTerminal.cols, shellTerminal.rows);
|
||||
}
|
||||
}
|
||||
};
|
||||
resizeAsync().catch(console.error);
|
||||
});
|
||||
|
||||
await window.kubernetesExecResize(id, shellTerminal.cols, shellTerminal.rows);
|
||||
}
|
||||
|
||||
function getSavedTerminalState(podName: string, containerName: string): State | undefined {
|
||||
let state;
|
||||
terminalStates.subscribe(states => {
|
||||
state = states.get(`${podName}-${containerName}`);
|
||||
})();
|
||||
return state ? (state as unknown as State) : undefined;
|
||||
}
|
||||
|
||||
function saveTerminalState(podName: string, containerName: string, state: State) {
|
||||
terminalStates.update(states => {
|
||||
states.set(`${podName}-${containerName}`, state);
|
||||
return states;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-full" bind:this="{terminalXtermDiv}"></div>
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import EmptyScreen from '/@/lib/ui/EmptyScreen.svelte';
|
||||
import NoLogIcon from '/@/lib/ui/NoLogIcon.svelte';
|
||||
import { podsInfos } from '/@/stores/pods';
|
||||
|
||||
import { terminalService } from './KubernetesTerminalService';
|
||||
import type { PodInfoUI } from './PodInfoUI';
|
||||
|
||||
let key = 0;
|
||||
|
||||
export let pod: PodInfoUI;
|
||||
|
||||
$: currentContainerStatus =
|
||||
$podsInfos
|
||||
.find(p => p.Name === pod.name)
|
||||
?.Containers.reduce((acc, c) => {
|
||||
acc.set(c.Names, c.Status);
|
||||
return acc;
|
||||
}, new Map<string, string>()) || new Map<string, string>();
|
||||
|
||||
let currentContainerName = '';
|
||||
|
||||
onMount(() => {
|
||||
if (pod.containers.length > 0) {
|
||||
currentContainerName = pod.containers[0].Names;
|
||||
terminalService.ensureTerminalExists(pod.name, currentContainerName);
|
||||
}
|
||||
key++;
|
||||
});
|
||||
|
||||
function handleSelectionChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
currentContainerName = target.value;
|
||||
terminalService.ensureTerminalExists(pod.name, currentContainerName);
|
||||
key++;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex py-2">
|
||||
<label
|
||||
for="input-standard-{pod.name}"
|
||||
class="block w-auto text-sm font-medium whitespace-nowrap leading-6 text-gray-900 pl-2 pr-2">
|
||||
{#key key}
|
||||
{#if terminalService.hasTerminal(pod.name, currentContainerName) && currentContainerStatus.get(currentContainerName) === 'running'}
|
||||
Connected to:
|
||||
{:else}
|
||||
Connecting to:
|
||||
{/if}
|
||||
{/key}
|
||||
</label>
|
||||
<div class="w-full">
|
||||
{#if pod.containers.length > 1}
|
||||
<select
|
||||
on:change="{handleSelectionChange}"
|
||||
aria-labelledby="listbox-label"
|
||||
class="block w-48 p-1 outline-none text-sm bg-charcoal-800 rounded-sm text-gray-700 placeholder-gray-700"
|
||||
name="{pod.name}"
|
||||
id="input-standard-{pod.name}">
|
||||
{#each pod.containers as container}
|
||||
<option value="{container.Names}">{container.Names}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<span
|
||||
id="input-standard-{pod.name}"
|
||||
class="block text-sm font-bold leading-6 text-gray-900"
|
||||
aria-labelledby="listbox-label">{currentContainerName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#key key}
|
||||
{#if terminalService.hasTerminal(pod.name, currentContainerName) && currentContainerStatus.get(currentContainerName) === 'running'}
|
||||
<svelte:component
|
||||
this="{terminalService.getTerminal(pod.name, currentContainerName).component}"
|
||||
{...terminalService.getTerminal(pod.name, currentContainerName).props} />
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
<EmptyScreen
|
||||
hidden="{!currentContainerStatus.get(currentContainerName)}"
|
||||
icon="{NoLogIcon}"
|
||||
title="No Terminal"
|
||||
message="Container is not running" />
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/**********************************************************************
|
||||
* Copyright (C) 2024 Red Hat, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
***********************************************************************/
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
import type { PodContainerInfo, PodInfo } from '@podman-desktop/api';
|
||||
import { beforeEach, expect, test } from 'vitest';
|
||||
|
||||
import { TerminalService } from '/@/lib/pod/KubernetesTerminalService';
|
||||
|
||||
let terminalService: TestableKubernetesTerminalService;
|
||||
|
||||
class TestableKubernetesTerminalService extends TerminalService {
|
||||
public testInvalidateCacheRecordOnStatusUpdate(podsInfos: PodInfo[]) {
|
||||
return this.invalidateCacheRecordOnStatusUpdate(podsInfos);
|
||||
}
|
||||
|
||||
public testToKey(podName: string, containerName: string): string {
|
||||
return super.toKey(podName, containerName);
|
||||
}
|
||||
|
||||
public testInvalidateCacheRecordOnPodRemove(podsInfos: PodInfo[]) {
|
||||
return this.invalidateCacheRecordOnPodRemove(podsInfos);
|
||||
}
|
||||
|
||||
public testTerminalCache() {
|
||||
return this.terminalCache;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
terminalService = new TestableKubernetesTerminalService();
|
||||
});
|
||||
|
||||
test('should create and cache a terminal if it does not exist', () => {
|
||||
const podName = 'pod1';
|
||||
const containerName = 'container1';
|
||||
|
||||
terminalService.ensureTerminalExists(podName, containerName);
|
||||
const terminal = terminalService.getTerminal(podName, containerName);
|
||||
|
||||
expect(terminal).toBeDefined();
|
||||
expect(terminal.props.podName).toBe(podName);
|
||||
expect(terminal.props.containerName).toBe(containerName);
|
||||
});
|
||||
|
||||
test('should check if the terminal exists in the cache', () => {
|
||||
const podName = 'pod1';
|
||||
const containerName = 'container1';
|
||||
|
||||
terminalService.ensureTerminalExists(podName, containerName);
|
||||
expect(terminalService.hasTerminal(podName, containerName)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should invalidate cache for non-running containers', () => {
|
||||
terminalService.testTerminalCache().set('pod1-container1', {});
|
||||
const podsInfosMock: PodInfo[] = [
|
||||
{
|
||||
Name: 'pod1',
|
||||
Containers: [{ Names: 'container1', Status: 'exited' } as PodContainerInfo],
|
||||
} as PodInfo,
|
||||
];
|
||||
|
||||
terminalService.testInvalidateCacheRecordOnStatusUpdate(podsInfosMock);
|
||||
expect(terminalService.hasTerminal('pod1', 'container1')).toBe(false);
|
||||
});
|
||||
|
||||
test('should invalidate cache when pod is removed', () => {
|
||||
terminalService.testTerminalCache().set('pod1-container1', {});
|
||||
terminalService.testInvalidateCacheRecordOnPodRemove([]);
|
||||
expect(terminalService.hasTerminal('pod1', 'container1')).toBe(false);
|
||||
});
|
||||
|
||||
test('should return correct key identifier', () => {
|
||||
const key = terminalService.testToKey('pod1', 'container1');
|
||||
expect(key).toBe('pod1-container1');
|
||||
});
|
||||
88
packages/renderer/src/lib/pod/KubernetesTerminalService.ts
Normal file
88
packages/renderer/src/lib/pod/KubernetesTerminalService.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**********************************************************************
|
||||
* Copyright (C) 2024 Red Hat, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
***********************************************************************/
|
||||
|
||||
import type { PodContainerInfo, PodInfo } from '@podman-desktop/api';
|
||||
|
||||
import KubernetesTerminal from '/@/lib/pod/KubernetesTerminal.svelte';
|
||||
import { terminalStates } from '/@/stores/kubernetes-terminal-state-store';
|
||||
import { podsInfos } from '/@/stores/pods';
|
||||
|
||||
export class TerminalService {
|
||||
protected terminalCache = new Map();
|
||||
|
||||
constructor() {
|
||||
podsInfos.subscribe($podsInfos => {
|
||||
this.invalidateCacheRecordOnStatusUpdate($podsInfos);
|
||||
this.invalidateCacheRecordOnPodRemove($podsInfos);
|
||||
});
|
||||
}
|
||||
|
||||
protected invalidateCacheRecordOnStatusUpdate(podsInfos: PodInfo[]) {
|
||||
podsInfos.forEach((pod: PodInfo) => {
|
||||
pod.Containers.forEach((container: PodContainerInfo) => {
|
||||
if (container.Status !== 'running') {
|
||||
this.terminalCache.delete(this.toKey(pod.Name, container.Names));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected invalidateCacheRecordOnPodRemove(podsInfos: PodInfo[]) {
|
||||
const activePods = new Set(
|
||||
podsInfos.flatMap((pod: PodInfo) =>
|
||||
pod.Containers.map((container: PodContainerInfo) => this.toKey(pod.Name, container.Names)),
|
||||
),
|
||||
);
|
||||
for (const [key] of this.terminalCache) {
|
||||
if (!activePods.has(key)) {
|
||||
this.invalidateTerminalComponentState(key);
|
||||
this.terminalCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureTerminalExists(podName: string, containerName: string) {
|
||||
if (!this.terminalCache.has(this.toKey(podName, containerName))) {
|
||||
this.terminalCache.set(this.toKey(podName, containerName), {
|
||||
component: KubernetesTerminal,
|
||||
props: { podName: podName, containerName },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getTerminal(podName: string, containerName: string) {
|
||||
return this.terminalCache.get(this.toKey(podName, containerName));
|
||||
}
|
||||
|
||||
hasTerminal(podName: string, containerName: string) {
|
||||
return this.terminalCache.has(this.toKey(podName, containerName));
|
||||
}
|
||||
|
||||
protected invalidateTerminalComponentState(podAndContainerName: string) {
|
||||
terminalStates.update(states => {
|
||||
states.delete(podAndContainerName);
|
||||
return states;
|
||||
});
|
||||
}
|
||||
|
||||
protected toKey(podName: string, containerName: string) {
|
||||
return `${podName}-${containerName}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const terminalService = new TerminalService();
|
||||
|
|
@ -10,6 +10,7 @@ import DetailsPage from '../ui/DetailsPage.svelte';
|
|||
import ErrorMessage from '../ui/ErrorMessage.svelte';
|
||||
import StateChange from '../ui/StateChange.svelte';
|
||||
import Tab from '../ui/Tab.svelte';
|
||||
import KubernetesTerminalBrowser from './KubernetesTerminalBrowser.svelte';
|
||||
import { PodUtils } from './pod-utils';
|
||||
import PodActions from './PodActions.svelte';
|
||||
import PodDetailsInspect from './PodDetailsInspect.svelte';
|
||||
|
|
@ -79,6 +80,9 @@ onMount(() => {
|
|||
<Tab title="Logs" url="logs" />
|
||||
<Tab title="Inspect" url="inspect" />
|
||||
<Tab title="Kube" url="kube" />
|
||||
{#if pod.kind === 'kubernetes'}
|
||||
<Tab title="Terminal" url="k8s-terminal" />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="content">
|
||||
<Route path="/summary" breadcrumb="Summary" navigationHint="tab">
|
||||
|
|
@ -93,6 +97,9 @@ onMount(() => {
|
|||
<Route path="/kube" breadcrumb="Kube" navigationHint="tab">
|
||||
<PodDetailsKube pod="{pod}" />
|
||||
</Route>
|
||||
<Route path="/k8s-terminal" breadcrumb="Terminal" navigationHint="tab">
|
||||
<KubernetesTerminalBrowser pod="{pod}" />
|
||||
</Route>
|
||||
</svelte:fragment>
|
||||
</DetailsPage>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
/**********************************************************************
|
||||
* Copyright (C) 2024 Red Hat, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
***********************************************************************/
|
||||
|
||||
import { render, waitFor } from '@testing-library/svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { beforeAll, expect, test, vi } from 'vitest';
|
||||
|
||||
import KubernetesTerminal from '/@/lib/pod/KubernetesTerminal.svelte';
|
||||
import { terminalStates } from '/@/stores/kubernetes-terminal-state-store';
|
||||
|
||||
const kubernetesExecMock = vi.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
(window as any).getConfigurationValue = vi.fn();
|
||||
(window as any).kubernetesExec = kubernetesExecMock;
|
||||
(window as any).kubernetesExecResize = vi.fn();
|
||||
|
||||
(window as any).matchMedia = vi.fn().mockReturnValue({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
test('Test should check saved terminal state after destroying terminal window', async () => {
|
||||
const sendCallbackId = 1;
|
||||
kubernetesExecMock.mockImplementation(
|
||||
(
|
||||
_podName: string,
|
||||
_containerName: string,
|
||||
_: (data: Buffer) => void,
|
||||
_onStdErr: (data: Buffer) => void,
|
||||
_onClose: () => void,
|
||||
) => {
|
||||
return sendCallbackId;
|
||||
},
|
||||
);
|
||||
|
||||
const renderObject = render(KubernetesTerminal, { podName: 'podName', containerName: 'containerName' });
|
||||
await waitFor(() => expect(kubernetesExecMock).toHaveBeenCalled());
|
||||
|
||||
const terminals = get(terminalStates);
|
||||
expect(terminals.size).toBe(0);
|
||||
|
||||
renderObject.component.$destroy();
|
||||
const terminalsAfterDestroy = get(terminalStates);
|
||||
expect(terminalsAfterDestroy.size).toBe(1);
|
||||
|
||||
const state = terminalsAfterDestroy.get('podName-containerName');
|
||||
|
||||
expect(state.id).toBe(sendCallbackId);
|
||||
expect(state.terminal).toBeDefined();
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/**********************************************************************
|
||||
* Copyright (C) 2024 Red Hat, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
***********************************************************************/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const terminalStates = writable(new Map());
|
||||
Loading…
Reference in a new issue