Skip to content

Commit 9ebdb91

Browse files
authored
feat: pod terminal (#292)
* feat: terminal api Signed-off-by: Philippe Martin <[email protected]> * feat: terminal webview Signed-off-by: Philippe Martin <[email protected]> * fix: save state in case user leaves webview Signed-off-by: Philippe Martin <[email protected]> * fix: setting size on stdout Signed-off-by: Philippe Martin <[email protected]> * fix: format Signed-off-by: Philippe Martin <[email protected]> * fix: linter Signed-off-by: Philippe Martin <[email protected]> --------- Signed-off-by: Philippe Martin <[email protected]>
1 parent 40a24ab commit 9ebdb91

File tree

18 files changed

+701
-3
lines changed

18 files changed

+701
-3
lines changed

packages/common/src/channels.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import type { ContextsApi } from './interface/contexts-api';
2020
import type { PodLogsApi } from './interface/pod-logs-api';
21+
import type { PodTerminalsApi } from './interface/pod-terminals-api';
2122
import type { PortForwardApi } from './interface/port-forward-api';
2223
import type { SubscribeApi } from './interface/subscribe-api';
2324
import type { SystemApi } from './interface/system-api';
@@ -27,6 +28,7 @@ import type { ContextsPermissionsInfo } from './model/contexts-permissions-info'
2728
import type { CurrentContextInfo } from './model/current-context-info';
2829
import type { EndpointsInfo } from './model/endpoints-info';
2930
import type { PodLogsChunk } from './model/pod-logs-chunk';
31+
import type { PodTerminalChunk } from './model/pod-terminal-chunk';
3032
import type { PortForwardsInfo } from './model/port-forward-info';
3133
import type { ResourceDetailsInfo } from './model/resource-details-info';
3234
import type { ResourceEventsInfo } from './model/resource-events-info';
@@ -56,3 +58,6 @@ export const ENDPOINTS = createRpcChannel<EndpointsInfo>('Endpoints');
5658
// Channels fot streams
5759
export const API_POD_LOGS = createRpcChannel<PodLogsApi>('PodLogsApi');
5860
export const POD_LOGS = createRpcChannel<PodLogsChunk>('PodLogs');
61+
62+
export const API_POD_TERMINALS = createRpcChannel<PodTerminalsApi>('PodTerminalsApi');
63+
export const POD_TERMINAL_DATA = createRpcChannel<PodTerminalChunk>('PodTerminalData');
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
export const PodTerminalsApi = Symbol.for('PodTerminalsApi');
20+
21+
export interface PodTerminalsApi {
22+
startTerminal(podName: string, namespace: string, containerName: string): Promise<void>;
23+
sendData(podName: string, namespace: string, containerName: string, data: string): Promise<void>;
24+
resizeTerminal(podName: string, namespace: string, containerName: string, cols: number, rows: number): Promise<void>;
25+
saveState(podName: string, namespace: string, containerName: string, state: string): Promise<void>;
26+
getState(podName: string, namespace: string, containerName: string): Promise<string>;
27+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
export interface PodTerminalChunk {
20+
podName: string;
21+
namespace: string;
22+
containerName: string;
23+
channel: 'stdout' | 'stderr';
24+
data: Buffer;
25+
}

packages/extension/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
"prettier": "^3.6.1",
3939
"typescript": "5.9.2",
4040
"vite": "^7.1",
41-
"vitest": "^3.1"
41+
"vitest": "^3.1",
42+
"isomorphic-ws": "^5.0.0",
43+
"@types/ws": "^8.18.0"
4244
},
4345
"dependencies": {
4446
"@kubernetes/client-node": "^1.3.0",

packages/extension/src/dashboard-extension.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,20 @@ import { KubeConfig } from '@kubernetes/client-node';
2828
import { ContextsStatesDispatcher } from '/@/manager/contexts-states-dispatcher';
2929
import { InversifyBinding } from '/@/inject/inversify-binding';
3030
import type { Container } from 'inversify';
31-
import { API_CONTEXTS, API_POD_LOGS, API_PORT_FORWARD, API_SUBSCRIBE, API_SYSTEM } from '/@common/channels';
31+
import {
32+
API_CONTEXTS,
33+
API_POD_LOGS,
34+
API_POD_TERMINALS,
35+
API_PORT_FORWARD,
36+
API_SUBSCRIBE,
37+
API_SYSTEM,
38+
} from '/@common/channels';
3239
import { SystemApiImpl } from './manager/system-api';
3340
import { PortForwardApiImpl } from './manager/port-forward-api-impl';
3441
import { PortForwardServiceProvider } from './port-forward/port-forward-service';
3542
import { PodLogsApiImpl } from './manager/pod-logs-api-impl';
3643
import { IDisposable } from '/@common/types/disposable';
44+
import { PodTerminalsApiImpl } from './manager/pod-terminals-api-impl';
3745

3846
export class DashboardExtension {
3947
#container: Container | undefined;
@@ -47,6 +55,7 @@ export class DashboardExtension {
4755
#portForwardApiImpl: PortForwardApiImpl;
4856
#portForwardServiceProvider: PortForwardServiceProvider;
4957
#podLogsApiImpl: PodLogsApiImpl;
58+
#podTerminalsApiImpl: PodTerminalsApiImpl;
5059

5160
constructor(readonly extensionContext: ExtensionContext) {
5261
this.#extensionContext = extensionContext;
@@ -72,6 +81,7 @@ export class DashboardExtension {
7281
this.#portForwardApiImpl = await this.#container.getAsync(PortForwardApiImpl);
7382
this.#portForwardServiceProvider = await this.#container.getAsync(PortForwardServiceProvider);
7483
this.#podLogsApiImpl = await this.#container.getAsync(PodLogsApiImpl);
84+
this.#podTerminalsApiImpl = await this.#container.getAsync(PodTerminalsApiImpl);
7585

7686
const afterFirst = performance.now();
7787

@@ -82,6 +92,7 @@ export class DashboardExtension {
8292
rpcExtension.registerInstance(API_SYSTEM, this.#systemApiImpl);
8393
rpcExtension.registerInstance(API_PORT_FORWARD, this.#portForwardApiImpl);
8494
rpcExtension.registerInstance(API_POD_LOGS, this.#podLogsApiImpl);
95+
rpcExtension.registerInstance(API_POD_TERMINALS, this.#podTerminalsApiImpl);
8596

8697
await this.listenMonitoring();
8798
await this.startMonitoring();

packages/extension/src/manager/_manager-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ import { SystemApiImpl } from './system-api';
2424
import { PortForwardApiImpl } from './port-forward-api-impl';
2525
import { PodLogsApiImpl } from './pod-logs-api-impl';
2626
import { IDisposable } from '/@common/types/disposable';
27+
import { PodTerminalsApiImpl } from './pod-terminals-api-impl';
2728

2829
const managersModule = new ContainerModule(options => {
2930
options.bind<ContextsManager>(ContextsManager).toSelf().inSingletonScope();
3031
options.bind<ContextsStatesDispatcher>(ContextsStatesDispatcher).toSelf().inSingletonScope();
3132
options.bind<SystemApiImpl>(SystemApiImpl).toSelf().inSingletonScope();
3233
options.bind<PortForwardApiImpl>(PortForwardApiImpl).toSelf().inSingletonScope();
3334
options.bind<PodLogsApiImpl>(PodLogsApiImpl).toSelf().inSingletonScope();
35+
options.bind<PodTerminalsApiImpl>(PodTerminalsApiImpl).toSelf().inSingletonScope();
3436

3537
// Bind IDisposable to services which need to clear data/stop connection/etc when the panel is left
3638
// (the onDestroy are not called from components when the panel is left, which may introduce memory leaks if not disposed here)
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**********************************************************************
2+
* Copyright (C) 2025 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import { inject, injectable } from 'inversify';
20+
import { IDisposable } from '/@common/types/disposable';
21+
import { PodTerminalsApi } from '/@common/interface/pod-terminals-api';
22+
import { PodTerminalService } from '../pod-terminal/pod-terminal-service';
23+
import { ContextsManager } from './contexts-manager';
24+
import { RpcExtension } from '/@common/rpc/rpc';
25+
26+
type PodTerminalInstance = {
27+
service: PodTerminalService;
28+
connected: boolean;
29+
};
30+
31+
@injectable()
32+
export class PodTerminalsApiImpl implements PodTerminalsApi, IDisposable {
33+
#instances: Map<string, PodTerminalInstance> = new Map();
34+
35+
constructor(
36+
@inject(ContextsManager) private contextsManager: ContextsManager,
37+
@inject(RpcExtension) private rpcExtension: RpcExtension,
38+
) {}
39+
40+
async startTerminal(podName: string, namespace: string, containerName: string): Promise<void> {
41+
if (!this.contextsManager.currentContext) {
42+
throw new Error('No current context found');
43+
}
44+
let instance: PodTerminalInstance | undefined = this.#instances.get(this.getKey(podName, namespace, containerName));
45+
46+
if (!instance) {
47+
instance = {
48+
service: new PodTerminalService(
49+
this.contextsManager.currentContext,
50+
this.rpcExtension,
51+
podName,
52+
namespace,
53+
containerName,
54+
),
55+
connected: false,
56+
};
57+
this.#instances.set(this.getKey(podName, namespace, containerName), instance);
58+
}
59+
60+
if (!instance.connected) {
61+
await instance.service.startTerminal(async (): Promise<void> => {
62+
if (instance) {
63+
instance.connected = false;
64+
}
65+
});
66+
instance.connected = true;
67+
}
68+
}
69+
70+
async sendData(podName: string, namespace: string, containerName: string, data: string): Promise<void> {
71+
const instance = this.#instances.get(this.getKey(podName, namespace, containerName));
72+
if (instance) {
73+
await instance.service.sendData(data);
74+
}
75+
}
76+
77+
async resizeTerminal(
78+
podName: string,
79+
namespace: string,
80+
containerName: string,
81+
cols: number,
82+
rows: number,
83+
): Promise<void> {
84+
const instance = this.#instances.get(this.getKey(podName, namespace, containerName));
85+
if (instance) {
86+
await instance.service.resizeTerminal(cols, rows);
87+
}
88+
}
89+
90+
async saveState(podName: string, namespace: string, containerName: string, state: string): Promise<void> {
91+
const instance = this.#instances.get(this.getKey(podName, namespace, containerName));
92+
if (instance) {
93+
await instance.service.saveState(state);
94+
}
95+
}
96+
97+
async getState(podName: string, namespace: string, containerName: string): Promise<string> {
98+
const instance = this.#instances.get(this.getKey(podName, namespace, containerName));
99+
if (instance) {
100+
return await instance.service.getState();
101+
}
102+
return '';
103+
}
104+
105+
getKey(podName: string, namespace: string, containerName: string): string {
106+
return `${podName}|${namespace}|${containerName}`;
107+
}
108+
109+
dispose(): void {
110+
this.#instances.forEach(instance => instance.service.stopTerminal());
111+
this.#instances.clear();
112+
}
113+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import { expect, test } from 'vitest';
20+
21+
import type { TerminalSize } from './exec-transmitter.js';
22+
import {
23+
BufferedStreamWriter,
24+
DEFAULT_COLUMNS,
25+
DEFAULT_ROWS,
26+
ResizableTerminalWriter,
27+
StringLineReader,
28+
} from './exec-transmitter.js';
29+
30+
test('Test should verify string line reader', () => {
31+
const reader = new StringLineReader();
32+
33+
reader.on('data', chunk => {
34+
expect(chunk.toString()).toEqual('foo');
35+
});
36+
37+
reader.push('foo');
38+
});
39+
40+
test('Test should verify buffered stream writer', () => {
41+
const writer = new BufferedStreamWriter((data: Buffer) => {
42+
expect(data.toString()).toEqual('foo');
43+
});
44+
45+
writer.write(Buffer.from('foo'));
46+
});
47+
48+
test('Test should verify resizable terminal writer', () => {
49+
const writer = new ResizableTerminalWriter(
50+
new BufferedStreamWriter((data: Buffer) => {
51+
expect(data.toString()).toEqual('foo');
52+
}),
53+
);
54+
55+
writer.on('resize', () => {
56+
const dimension = writer.getDimension();
57+
expect(dimension).toEqual({ width: 1, height: 1 } as TerminalSize);
58+
});
59+
60+
writer.write(Buffer.from('foo'));
61+
62+
expect(writer.getDimension()).toEqual({ width: DEFAULT_COLUMNS, height: DEFAULT_ROWS } as TerminalSize);
63+
writer.resize({ width: 1, height: 1 } as TerminalSize);
64+
expect(writer.getDimension()).toEqual({ width: 1, height: 1 } as TerminalSize);
65+
});

0 commit comments

Comments
 (0)