Skip to content

Commit 4d420cb

Browse files
authored
feat: support task redirection (#1745)
* feat: support task redirection Signed-off-by: axel7083 <[email protected]> * fix: linter&prettier Signed-off-by: axel7083 <[email protected]> * fix: typecheck Signed-off-by: axel7083 <[email protected]> --------- Signed-off-by: axel7083 <[email protected]>
1 parent 46f4759 commit 4d420cb

File tree

14 files changed

+373
-19
lines changed

14 files changed

+373
-19
lines changed

packages/backend/package.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@
1212
},
1313
"main": "./dist/extension.cjs",
1414
"contributes": {
15+
"commands": [
16+
{
17+
"command": "ai-lab.navigation.inference.start",
18+
"title": "AI Lab: navigate to inference start page",
19+
"hidden": true
20+
},
21+
{
22+
"command": "ai-lab.navigation.recipe.start",
23+
"title": "AI Lab: navigate to recipe start page",
24+
"hidden": true
25+
}
26+
],
1527
"configuration": {
1628
"title": "AI Lab",
1729
"properties": {
@@ -96,7 +108,7 @@
96108
"xml-js": "^1.6.11"
97109
},
98110
"devDependencies": {
99-
"@podman-desktop/api": "1.12.0",
111+
"@podman-desktop/api": "1.13.0-202409181313-78725a6565",
100112
"@rollup/plugin-replace": "^6.0.1",
101113
"@types/express": "^4.17.21",
102114
"@types/js-yaml": "^4.0.9",

packages/backend/src/managers/application/applicationManager.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
POD_LABEL_RECIPE_ID,
5353
} from '../../utils/RecipeConstants';
5454
import { VMType } from '@shared/src/models/IPodman';
55+
import { RECIPE_START_ROUTE } from '../../registries/NavigationRegistry';
5556

5657
export class ApplicationManager extends Publisher<ApplicationState[]> implements Disposable {
5758
#applications: ApplicationRegistry<ApplicationState>;
@@ -91,8 +92,16 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
9192
});
9293

9394
window
94-
.withProgress({ location: ProgressLocation.TASK_WIDGET, title: `Pulling ${recipe.name}.` }, () =>
95-
this.pullApplication(connection, recipe, model, labels),
95+
.withProgress(
96+
{
97+
location: ProgressLocation.TASK_WIDGET,
98+
title: `Pulling ${recipe.name}.`,
99+
details: {
100+
routeId: RECIPE_START_ROUTE,
101+
routeArgs: [recipe.id, trackingId],
102+
},
103+
},
104+
() => this.pullApplication(connection, recipe, model, labels),
96105
)
97106
.then(() => {
98107
task.state = 'success';
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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 { beforeAll, afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
20+
import { commands, navigation, type WebviewPanel, type Disposable } from '@podman-desktop/api';
21+
import { NavigationRegistry } from './NavigationRegistry';
22+
import { Messages } from '@shared/Messages';
23+
24+
vi.mock('@podman-desktop/api', async () => ({
25+
commands: {
26+
registerCommand: vi.fn(),
27+
},
28+
navigation: {
29+
register: vi.fn(),
30+
},
31+
}));
32+
33+
const panelMock: WebviewPanel = {
34+
reveal: vi.fn(),
35+
webview: {
36+
postMessage: vi.fn(),
37+
},
38+
} as unknown as WebviewPanel;
39+
40+
beforeEach(() => {
41+
vi.resetAllMocks();
42+
vi.restoreAllMocks();
43+
});
44+
45+
describe('incompatible podman-desktop', () => {
46+
let register: typeof navigation.register | undefined;
47+
beforeAll(() => {
48+
register = navigation.register;
49+
(navigation.register as unknown as undefined) = undefined;
50+
});
51+
52+
afterAll(() => {
53+
if (!register) return;
54+
navigation.register = register;
55+
});
56+
57+
test('init should not register command and navigation when using old version of podman', () => {
58+
(navigation.register as unknown as undefined) = undefined;
59+
const registry = new NavigationRegistry(panelMock);
60+
registry.init();
61+
62+
expect(commands.registerCommand).not.toHaveBeenCalled();
63+
});
64+
});
65+
66+
test('init should register command and navigation', () => {
67+
const registry = new NavigationRegistry(panelMock);
68+
registry.init();
69+
70+
expect(commands.registerCommand).toHaveBeenCalled();
71+
expect(navigation.register).toHaveBeenCalled();
72+
});
73+
74+
test('dispose should dispose all command and navigation registered', () => {
75+
const registry = new NavigationRegistry(panelMock);
76+
const disposables: Disposable[] = [];
77+
vi.mocked(commands.registerCommand).mockImplementation(() => {
78+
const disposable: Disposable = {
79+
dispose: vi.fn(),
80+
};
81+
disposables.push(disposable);
82+
return disposable;
83+
});
84+
vi.mocked(navigation.register).mockImplementation(() => {
85+
const disposable: Disposable = {
86+
dispose: vi.fn(),
87+
};
88+
disposables.push(disposable);
89+
return disposable;
90+
});
91+
92+
registry.dispose();
93+
94+
disposables.forEach((disposable: Disposable) => {
95+
expect(disposable.dispose).toHaveBeenCalledOnce();
96+
});
97+
});
98+
99+
test('navigateToInferenceCreate should reveal and postMessage to webview', async () => {
100+
const registry = new NavigationRegistry(panelMock);
101+
102+
await registry.navigateToInferenceCreate('dummyTrackingId');
103+
104+
await vi.waitFor(() => {
105+
expect(panelMock.reveal).toHaveBeenCalledOnce();
106+
});
107+
108+
expect(panelMock.webview.postMessage).toHaveBeenCalledWith({
109+
id: Messages.MSG_NAVIGATION_ROUTE_UPDATE,
110+
body: '/service/create?trackingId=dummyTrackingId',
111+
});
112+
});
113+
114+
test('navigateToRecipeStart should reveal and postMessage to webview', async () => {
115+
const registry = new NavigationRegistry(panelMock);
116+
117+
await registry.navigateToRecipeStart('dummyRecipeId', 'dummyTrackingId');
118+
119+
await vi.waitFor(() => {
120+
expect(panelMock.reveal).toHaveBeenCalledOnce();
121+
});
122+
123+
expect(panelMock.webview.postMessage).toHaveBeenCalledWith({
124+
id: Messages.MSG_NAVIGATION_ROUTE_UPDATE,
125+
body: '/recipe/dummyRecipeId/start?trackingId=dummyTrackingId',
126+
});
127+
});
128+
129+
test('reading the route has side-effect', async () => {
130+
const registry = new NavigationRegistry(panelMock);
131+
132+
await registry.navigateToRecipeStart('dummyRecipeId', 'dummyTrackingId');
133+
134+
expect(registry.readRoute()).toBeDefined();
135+
expect(registry.readRoute()).toBeUndefined();
136+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
import { type Disposable, navigation, type WebviewPanel, commands } from '@podman-desktop/api';
19+
import { Messages } from '@shared/Messages';
20+
21+
export const RECIPE_START_ROUTE = 'recipe.start';
22+
export const RECIPE_START_NAVIGATE_COMMAND = 'ai-lab.navigation.recipe.start';
23+
24+
export const INFERENCE_CREATE_ROUTE = 'inference.create';
25+
export const INFERENCE_CREATE_NAVIGATE_COMMAND = 'ai-lab.navigation.inference.create';
26+
27+
export class NavigationRegistry implements Disposable {
28+
#disposables: Disposable[] = [];
29+
#route: string | undefined = undefined;
30+
31+
constructor(private panel: WebviewPanel) {}
32+
33+
init(): void {
34+
if (!navigation.register) {
35+
console.warn('this version of podman-desktop do not support task actions: some feature will not be available.');
36+
return;
37+
}
38+
39+
// register the recipes start navigation and command
40+
this.#disposables.push(
41+
commands.registerCommand(RECIPE_START_NAVIGATE_COMMAND, this.navigateToRecipeStart.bind(this)),
42+
);
43+
this.#disposables.push(navigation.register(RECIPE_START_ROUTE, RECIPE_START_NAVIGATE_COMMAND));
44+
45+
// register the inference create navigation and command
46+
this.#disposables.push(
47+
commands.registerCommand(INFERENCE_CREATE_NAVIGATE_COMMAND, this.navigateToInferenceCreate.bind(this)),
48+
);
49+
this.#disposables.push(navigation.register(INFERENCE_CREATE_ROUTE, INFERENCE_CREATE_NAVIGATE_COMMAND));
50+
}
51+
52+
/**
53+
* This function return the route, and reset it.
54+
* Meaning after read the route is undefined
55+
*/
56+
public readRoute(): string | undefined {
57+
const result: string | undefined = this.#route;
58+
this.#route = undefined;
59+
return result;
60+
}
61+
62+
dispose(): void {
63+
this.#disposables.forEach(disposable => disposable.dispose());
64+
}
65+
66+
protected async updateRoute(route: string): Promise<void> {
67+
await this.panel.webview.postMessage({
68+
id: Messages.MSG_NAVIGATION_ROUTE_UPDATE,
69+
body: route,
70+
});
71+
this.#route = route;
72+
this.panel.reveal();
73+
}
74+
75+
public async navigateToRecipeStart(recipeId: string, trackingId: string): Promise<void> {
76+
return this.updateRoute(`/recipe/${recipeId}/start?trackingId=${trackingId}`);
77+
}
78+
79+
public async navigateToInferenceCreate(trackingId: string): Promise<void> {
80+
return this.updateRoute(`/service/create?trackingId=${trackingId}`);
81+
}
82+
}

packages/backend/src/studio-api-impl.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import * as podman from './utils/podman';
4646
import type { ConfigurationRegistry } from './registries/ConfigurationRegistry';
4747
import type { RecipeManager } from './managers/recipes/RecipeManager';
4848
import type { PodmanConnection } from './managers/podmanConnection';
49+
import type { NavigationRegistry } from './registries/NavigationRegistry';
4950

5051
vi.mock('./ai.json', () => {
5152
return {
@@ -158,6 +159,7 @@ beforeEach(async () => {
158159
{} as unknown as ConfigurationRegistry,
159160
{} as unknown as RecipeManager,
160161
podmanConnectionMock,
162+
{} as unknown as NavigationRegistry,
161163
);
162164
vi.mock('node:fs');
163165

packages/backend/src/studio-api-impl.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import type { RecipeManager } from './managers/recipes/RecipeManager';
5555
import type { PodmanConnection } from './managers/podmanConnection';
5656
import type { RecipePullOptions } from '@shared/src/models/IRecipe';
5757
import type { ContainerProviderConnection } from '@podman-desktop/api';
58+
import type { NavigationRegistry } from './registries/NavigationRegistry';
5859

5960
interface PortQuickPickItem extends podmanDesktopApi.QuickPickItem {
6061
port: number;
@@ -75,8 +76,13 @@ export class StudioApiImpl implements StudioAPI {
7576
private configurationRegistry: ConfigurationRegistry,
7677
private recipeManager: RecipeManager,
7778
private podmanConnection: PodmanConnection,
79+
private navigationRegistry: NavigationRegistry,
7880
) {}
7981

82+
async readRoute(): Promise<string | undefined> {
83+
return this.navigationRegistry.readRoute();
84+
}
85+
8086
async requestDeleteConversation(conversationId: string): Promise<void> {
8187
// Do not wait on the promise as the api would probably timeout before the user answer.
8288
podmanDesktopApi.window

packages/backend/src/studio.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ vi.mock('@podman-desktop/api', async () => {
9191
onEvent: vi.fn(),
9292
listContainers: mocks.listContainers,
9393
},
94+
navigation: {
95+
register: vi.fn(),
96+
},
9497
provider: {
9598
onDidRegisterContainerConnection: vi.fn(),
9699
onDidUpdateContainerConnection: vi.fn(),

packages/backend/src/studio.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { WhisperCpp } from './workers/provider/WhisperCpp';
5151
import { ApiServer } from './managers/apiServer';
5252
import { InstructlabManager } from './managers/instructlab/instructlabManager';
5353
import { InstructlabApiImpl } from './instructlab-api-impl';
54+
import { NavigationRegistry } from './registries/NavigationRegistry';
5455

5556
export class Studio {
5657
readonly #extensionContext: ExtensionContext;
@@ -85,6 +86,7 @@ export class Studio {
8586
#inferenceProviderRegistry: InferenceProviderRegistry | undefined;
8687
#configurationRegistry: ConfigurationRegistry | undefined;
8788
#gpuManager: GPUManager | undefined;
89+
#navigationRegistry: NavigationRegistry | undefined;
8890
#instructlabManager: InstructlabManager | undefined;
8991

9092
constructor(readonly extensionContext: ExtensionContext) {
@@ -137,6 +139,14 @@ export class Studio {
137139
this.#telemetry?.logUsage(e.webviewPanel.visible ? 'opened' : 'closed');
138140
});
139141

142+
/**
143+
* The navigation registry is used
144+
* to register and managed the routes of the extension
145+
*/
146+
this.#navigationRegistry = new NavigationRegistry(this.#panel);
147+
this.#navigationRegistry.init();
148+
this.#extensionContext.subscriptions.push(this.#navigationRegistry);
149+
140150
/**
141151
* Cancellation token registry store the tokens used to cancel a task
142152
*/
@@ -333,6 +343,7 @@ export class Studio {
333343
this.#configurationRegistry,
334344
this.#recipeManager,
335345
this.#podmanConnection,
346+
this.#navigationRegistry,
336347
);
337348
// Register the instance
338349
this.#rpcExtension.registerInstance<StudioApiImpl>(StudioApiImpl, this.#studioApi);

0 commit comments

Comments
 (0)