diff --git a/packages/backend/package.json b/packages/backend/package.json index c83b681c0..75b8e6519 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -12,6 +12,18 @@ }, "main": "./dist/extension.cjs", "contributes": { + "commands": [ + { + "command": "ai-lab.navigation.inference.start", + "title": "AI Lab: navigate to inference start page", + "hidden": true + }, + { + "command": "ai-lab.navigation.recipe.start", + "title": "AI Lab: navigate to recipe start page", + "hidden": true + } + ], "configuration": { "title": "AI Lab", "properties": { @@ -96,7 +108,7 @@ "xml-js": "^1.6.11" }, "devDependencies": { - "@podman-desktop/api": "1.12.0", + "@podman-desktop/api": "1.13.0-202409181313-78725a6565", "@rollup/plugin-replace": "^6.0.1", "@types/express": "^4.17.21", "@types/js-yaml": "^4.0.9", diff --git a/packages/backend/src/managers/application/applicationManager.ts b/packages/backend/src/managers/application/applicationManager.ts index 55672e8c1..4a5f20169 100644 --- a/packages/backend/src/managers/application/applicationManager.ts +++ b/packages/backend/src/managers/application/applicationManager.ts @@ -52,6 +52,7 @@ import { POD_LABEL_RECIPE_ID, } from '../../utils/RecipeConstants'; import { VMType } from '@shared/src/models/IPodman'; +import { RECIPE_START_ROUTE } from '../../registries/NavigationRegistry'; export class ApplicationManager extends Publisher implements Disposable { #applications: ApplicationRegistry; @@ -91,8 +92,16 @@ export class ApplicationManager extends Publisher implements }); window - .withProgress({ location: ProgressLocation.TASK_WIDGET, title: `Pulling ${recipe.name}.` }, () => - this.pullApplication(connection, recipe, model, labels), + .withProgress( + { + location: ProgressLocation.TASK_WIDGET, + title: `Pulling ${recipe.name}.`, + details: { + routeId: RECIPE_START_ROUTE, + routeArgs: [recipe.id, trackingId], + }, + }, + () => this.pullApplication(connection, recipe, model, labels), ) .then(() => { task.state = 'success'; diff --git a/packages/backend/src/registries/NavigationRegistry.spec.ts b/packages/backend/src/registries/NavigationRegistry.spec.ts new file mode 100644 index 000000000..892610310 --- /dev/null +++ b/packages/backend/src/registries/NavigationRegistry.spec.ts @@ -0,0 +1,136 @@ +/********************************************************************** + * 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 { beforeAll, afterAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { commands, navigation, type WebviewPanel, type Disposable } from '@podman-desktop/api'; +import { NavigationRegistry } from './NavigationRegistry'; +import { Messages } from '@shared/Messages'; + +vi.mock('@podman-desktop/api', async () => ({ + commands: { + registerCommand: vi.fn(), + }, + navigation: { + register: vi.fn(), + }, +})); + +const panelMock: WebviewPanel = { + reveal: vi.fn(), + webview: { + postMessage: vi.fn(), + }, +} as unknown as WebviewPanel; + +beforeEach(() => { + vi.resetAllMocks(); + vi.restoreAllMocks(); +}); + +describe('incompatible podman-desktop', () => { + let register: typeof navigation.register | undefined; + beforeAll(() => { + register = navigation.register; + (navigation.register as unknown as undefined) = undefined; + }); + + afterAll(() => { + if (!register) return; + navigation.register = register; + }); + + test('init should not register command and navigation when using old version of podman', () => { + (navigation.register as unknown as undefined) = undefined; + const registry = new NavigationRegistry(panelMock); + registry.init(); + + expect(commands.registerCommand).not.toHaveBeenCalled(); + }); +}); + +test('init should register command and navigation', () => { + const registry = new NavigationRegistry(panelMock); + registry.init(); + + expect(commands.registerCommand).toHaveBeenCalled(); + expect(navigation.register).toHaveBeenCalled(); +}); + +test('dispose should dispose all command and navigation registered', () => { + const registry = new NavigationRegistry(panelMock); + const disposables: Disposable[] = []; + vi.mocked(commands.registerCommand).mockImplementation(() => { + const disposable: Disposable = { + dispose: vi.fn(), + }; + disposables.push(disposable); + return disposable; + }); + vi.mocked(navigation.register).mockImplementation(() => { + const disposable: Disposable = { + dispose: vi.fn(), + }; + disposables.push(disposable); + return disposable; + }); + + registry.dispose(); + + disposables.forEach((disposable: Disposable) => { + expect(disposable.dispose).toHaveBeenCalledOnce(); + }); +}); + +test('navigateToInferenceCreate should reveal and postMessage to webview', async () => { + const registry = new NavigationRegistry(panelMock); + + await registry.navigateToInferenceCreate('dummyTrackingId'); + + await vi.waitFor(() => { + expect(panelMock.reveal).toHaveBeenCalledOnce(); + }); + + expect(panelMock.webview.postMessage).toHaveBeenCalledWith({ + id: Messages.MSG_NAVIGATION_ROUTE_UPDATE, + body: '/service/create?trackingId=dummyTrackingId', + }); +}); + +test('navigateToRecipeStart should reveal and postMessage to webview', async () => { + const registry = new NavigationRegistry(panelMock); + + await registry.navigateToRecipeStart('dummyRecipeId', 'dummyTrackingId'); + + await vi.waitFor(() => { + expect(panelMock.reveal).toHaveBeenCalledOnce(); + }); + + expect(panelMock.webview.postMessage).toHaveBeenCalledWith({ + id: Messages.MSG_NAVIGATION_ROUTE_UPDATE, + body: '/recipe/dummyRecipeId/start?trackingId=dummyTrackingId', + }); +}); + +test('reading the route has side-effect', async () => { + const registry = new NavigationRegistry(panelMock); + + await registry.navigateToRecipeStart('dummyRecipeId', 'dummyTrackingId'); + + expect(registry.readRoute()).toBeDefined(); + expect(registry.readRoute()).toBeUndefined(); +}); diff --git a/packages/backend/src/registries/NavigationRegistry.ts b/packages/backend/src/registries/NavigationRegistry.ts new file mode 100644 index 000000000..59c4bc2c9 --- /dev/null +++ b/packages/backend/src/registries/NavigationRegistry.ts @@ -0,0 +1,82 @@ +/********************************************************************** + * 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 Disposable, navigation, type WebviewPanel, commands } from '@podman-desktop/api'; +import { Messages } from '@shared/Messages'; + +export const RECIPE_START_ROUTE = 'recipe.start'; +export const RECIPE_START_NAVIGATE_COMMAND = 'ai-lab.navigation.recipe.start'; + +export const INFERENCE_CREATE_ROUTE = 'inference.create'; +export const INFERENCE_CREATE_NAVIGATE_COMMAND = 'ai-lab.navigation.inference.create'; + +export class NavigationRegistry implements Disposable { + #disposables: Disposable[] = []; + #route: string | undefined = undefined; + + constructor(private panel: WebviewPanel) {} + + init(): void { + if (!navigation.register) { + console.warn('this version of podman-desktop do not support task actions: some feature will not be available.'); + return; + } + + // register the recipes start navigation and command + this.#disposables.push( + commands.registerCommand(RECIPE_START_NAVIGATE_COMMAND, this.navigateToRecipeStart.bind(this)), + ); + this.#disposables.push(navigation.register(RECIPE_START_ROUTE, RECIPE_START_NAVIGATE_COMMAND)); + + // register the inference create navigation and command + this.#disposables.push( + commands.registerCommand(INFERENCE_CREATE_NAVIGATE_COMMAND, this.navigateToInferenceCreate.bind(this)), + ); + this.#disposables.push(navigation.register(INFERENCE_CREATE_ROUTE, INFERENCE_CREATE_NAVIGATE_COMMAND)); + } + + /** + * This function return the route, and reset it. + * Meaning after read the route is undefined + */ + public readRoute(): string | undefined { + const result: string | undefined = this.#route; + this.#route = undefined; + return result; + } + + dispose(): void { + this.#disposables.forEach(disposable => disposable.dispose()); + } + + protected async updateRoute(route: string): Promise { + await this.panel.webview.postMessage({ + id: Messages.MSG_NAVIGATION_ROUTE_UPDATE, + body: route, + }); + this.#route = route; + this.panel.reveal(); + } + + public async navigateToRecipeStart(recipeId: string, trackingId: string): Promise { + return this.updateRoute(`/recipe/${recipeId}/start?trackingId=${trackingId}`); + } + + public async navigateToInferenceCreate(trackingId: string): Promise { + return this.updateRoute(`/service/create?trackingId=${trackingId}`); + } +} diff --git a/packages/backend/src/studio-api-impl.spec.ts b/packages/backend/src/studio-api-impl.spec.ts index cbe013590..a8e7a41e2 100644 --- a/packages/backend/src/studio-api-impl.spec.ts +++ b/packages/backend/src/studio-api-impl.spec.ts @@ -46,6 +46,7 @@ import * as podman from './utils/podman'; import type { ConfigurationRegistry } from './registries/ConfigurationRegistry'; import type { RecipeManager } from './managers/recipes/RecipeManager'; import type { PodmanConnection } from './managers/podmanConnection'; +import type { NavigationRegistry } from './registries/NavigationRegistry'; vi.mock('./ai.json', () => { return { @@ -158,6 +159,7 @@ beforeEach(async () => { {} as unknown as ConfigurationRegistry, {} as unknown as RecipeManager, podmanConnectionMock, + {} as unknown as NavigationRegistry, ); vi.mock('node:fs'); diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index 520b0d0b8..0577ce50a 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -55,6 +55,7 @@ import type { RecipeManager } from './managers/recipes/RecipeManager'; import type { PodmanConnection } from './managers/podmanConnection'; import type { RecipePullOptions } from '@shared/src/models/IRecipe'; import type { ContainerProviderConnection } from '@podman-desktop/api'; +import type { NavigationRegistry } from './registries/NavigationRegistry'; interface PortQuickPickItem extends podmanDesktopApi.QuickPickItem { port: number; @@ -75,8 +76,13 @@ export class StudioApiImpl implements StudioAPI { private configurationRegistry: ConfigurationRegistry, private recipeManager: RecipeManager, private podmanConnection: PodmanConnection, + private navigationRegistry: NavigationRegistry, ) {} + async readRoute(): Promise { + return this.navigationRegistry.readRoute(); + } + async requestDeleteConversation(conversationId: string): Promise { // Do not wait on the promise as the api would probably timeout before the user answer. podmanDesktopApi.window diff --git a/packages/backend/src/studio.spec.ts b/packages/backend/src/studio.spec.ts index edff0dc10..28cba3267 100644 --- a/packages/backend/src/studio.spec.ts +++ b/packages/backend/src/studio.spec.ts @@ -91,6 +91,9 @@ vi.mock('@podman-desktop/api', async () => { onEvent: vi.fn(), listContainers: mocks.listContainers, }, + navigation: { + register: vi.fn(), + }, provider: { onDidRegisterContainerConnection: vi.fn(), onDidUpdateContainerConnection: vi.fn(), diff --git a/packages/backend/src/studio.ts b/packages/backend/src/studio.ts index 7d25a1108..d1e50a4e6 100644 --- a/packages/backend/src/studio.ts +++ b/packages/backend/src/studio.ts @@ -51,6 +51,7 @@ import { WhisperCpp } from './workers/provider/WhisperCpp'; import { ApiServer } from './managers/apiServer'; import { InstructlabManager } from './managers/instructlab/instructlabManager'; import { InstructlabApiImpl } from './instructlab-api-impl'; +import { NavigationRegistry } from './registries/NavigationRegistry'; export class Studio { readonly #extensionContext: ExtensionContext; @@ -85,6 +86,7 @@ export class Studio { #inferenceProviderRegistry: InferenceProviderRegistry | undefined; #configurationRegistry: ConfigurationRegistry | undefined; #gpuManager: GPUManager | undefined; + #navigationRegistry: NavigationRegistry | undefined; #instructlabManager: InstructlabManager | undefined; constructor(readonly extensionContext: ExtensionContext) { @@ -137,6 +139,14 @@ export class Studio { this.#telemetry?.logUsage(e.webviewPanel.visible ? 'opened' : 'closed'); }); + /** + * The navigation registry is used + * to register and managed the routes of the extension + */ + this.#navigationRegistry = new NavigationRegistry(this.#panel); + this.#navigationRegistry.init(); + this.#extensionContext.subscriptions.push(this.#navigationRegistry); + /** * Cancellation token registry store the tokens used to cancel a task */ @@ -333,6 +343,7 @@ export class Studio { this.#configurationRegistry, this.#recipeManager, this.#podmanConnection, + this.#navigationRegistry, ); // Register the instance this.#rpcExtension.registerInstance(StudioApiImpl, this.#studioApi); diff --git a/packages/frontend/src/App.spec.ts b/packages/frontend/src/App.spec.ts new file mode 100644 index 000000000..c29da0378 --- /dev/null +++ b/packages/frontend/src/App.spec.ts @@ -0,0 +1,71 @@ +/********************************************************************** + * 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 { vi, beforeEach, test, expect } from 'vitest'; +import { render } from '@testing-library/svelte'; +import App from '/@/App.svelte'; +import { getRouterState, rpcBrowser } from '/@/utils/client'; +import { configuration } from '/@/stores/extensionConfiguration'; +import { Messages } from '@shared/Messages'; + +vi.mock('tinro', () => ({ + router: { + goto: vi.fn(), + mode: { + hash: vi.fn(), + }, + location: { + query: new Map(), + }, + }, +})); + +vi.mock('./stores/extensionConfiguration.ts', () => ({ + configuration: { + subscribe: vi.fn(), + }, +})); + +vi.mock('./utils/client', async () => ({ + studioClient: { + getExtensionConfiguration: vi.fn(), + }, + instructlabClient: {}, + rpcBrowser: { + subscribe: vi.fn(), + }, + getRouterState: vi.fn(), + saveRouterState: vi.fn(), +})); + +beforeEach(() => { + vi.resetAllMocks(); + + vi.mocked(getRouterState).mockResolvedValue({ url: '/' }); + vi.mocked(rpcBrowser.subscribe).mockReturnValue({ unsubscribe: vi.fn() }); + vi.mocked(configuration.subscribe).mockReturnValue(vi.fn()); +}); + +test('should subscribe to navigation update route on mount', async () => { + render(App, {}); + + await vi.waitFor(() => { + expect(rpcBrowser.subscribe).toHaveBeenCalledWith(Messages.MSG_NAVIGATION_ROUTE_UPDATE, expect.any(Function)); + }); +}); diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index b8683723c..674d01c60 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -12,7 +12,7 @@ import Models from '/@/pages/Models.svelte'; import Recipe from '/@/pages/Recipe.svelte'; import Model from './pages/Model.svelte'; import { onDestroy, onMount } from 'svelte'; -import { getRouterState } from '/@/utils/client'; +import { getRouterState, rpcBrowser } from '/@/utils/client'; import CreateService from '/@/pages/CreateService.svelte'; import Services from '/@/pages/InferenceServers.svelte'; import ServiceDetails from '/@/pages/InferenceServerDetails.svelte'; @@ -25,27 +25,36 @@ import TuneSessions from './pages/TuneSessions.svelte'; import { configuration } from './stores/extensionConfiguration'; import type { ExtensionConfiguration } from '@shared/src/models/IExtensionConfiguration'; import type { Unsubscriber } from 'svelte/store'; +import { Messages } from '@shared/Messages'; router.mode.hash(); let isMounted = false; let experimentalTuning: boolean = false; -let cfgUnsubscribe: Unsubscriber; +const unsubscribers: Unsubscriber[] = []; -onMount(() => { +onMount(async () => { // Load router state on application startup - const state = getRouterState(); + const state = await getRouterState(); router.goto(state.url); isMounted = true; - cfgUnsubscribe = configuration.subscribe((val: ExtensionConfiguration | undefined) => { - experimentalTuning = val?.experimentalTuning ?? false; - }); + unsubscribers.push( + configuration.subscribe((val: ExtensionConfiguration | undefined) => { + experimentalTuning = val?.experimentalTuning ?? false; + }), + ); + + unsubscribers.push( + rpcBrowser.subscribe(Messages.MSG_NAVIGATION_ROUTE_UPDATE, location => { + router.goto(location); + }).unsubscribe, + ); }); onDestroy(() => { - cfgUnsubscribe?.(); + unsubscribers.forEach(unsubscriber => unsubscriber()); }); diff --git a/packages/frontend/src/utils/client.ts b/packages/frontend/src/utils/client.ts index 19e67869a..397a0e659 100644 --- a/packages/frontend/src/utils/client.ts +++ b/packages/frontend/src/utils/client.ts @@ -21,7 +21,6 @@ import { RpcBrowser } from '@shared/src/messages/MessageProxy'; import type { RouterState } from '/@/models/IRouterState'; import type { InstructlabAPI } from '@shared/src/InstructlabAPI'; -export const RECENT_CATEGORY_ID = 'recent-category'; const podmanDesktopApi = acquirePodmanDesktopApi(); export const rpcBrowser: RpcBrowser = new RpcBrowser(window, podmanDesktopApi); @@ -36,11 +35,18 @@ const isRouterState = (value: unknown): value is RouterState => { return typeof value === 'object' && !!value && 'url' in value; }; -export const getRouterState = (): RouterState => { +export async function getRouterState(): Promise { + const route: string | undefined = await studioClient.readRoute(); + if (route) { + return { + url: route, + }; + } + const state = podmanDesktopApi.getState(); if (isRouterState(state)) return state; return { url: '/' }; -}; +} Object.defineProperty(window, 'studioClient', { value: studioClient, diff --git a/packages/shared/Messages.ts b/packages/shared/Messages.ts index 530c0e9fa..76416d72c 100644 --- a/packages/shared/Messages.ts +++ b/packages/shared/Messages.ts @@ -31,4 +31,5 @@ export enum Messages { MSG_CONFIGURATION_UPDATE = 'configuration-update', MSG_PODMAN_CONNECTION_UPDATE = 'podman-connecting-update', MSG_INSTRUCTLAB_SESSIONS_UPDATE = 'instructlab-sessions-update', + MSG_NAVIGATION_ROUTE_UPDATE = 'navigation-route-update', } diff --git a/packages/shared/src/StudioAPI.ts b/packages/shared/src/StudioAPI.ts index bd30afa06..f83212789 100644 --- a/packages/shared/src/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -236,4 +236,10 @@ export abstract class StudioAPI { abstract checkContainerConnectionStatusAndResources( options: CheckContainerConnectionResourcesOptions, ): Promise; + + /** + * This method is used by the frontend on reveal to get any potential navigation + * route it should use. This method has a side effect of removing the pending route after calling. + */ + abstract readRoute(): Promise; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df894f71d..18b03f79d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,8 +136,8 @@ importers: version: 1.6.11 devDependencies: '@podman-desktop/api': - specifier: 1.12.0 - version: 1.12.0 + specifier: 1.13.0-202409181313-78725a6565 + version: 1.13.0-202409181313-78725a6565 '@rollup/plugin-replace': specifier: ^6.0.1 version: 6.0.1(rollup@4.21.3) @@ -1219,8 +1219,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@podman-desktop/api@1.12.0': - resolution: {integrity: sha512-yZmrqVlXftnK8JcXab6tEdLy7h6Sf6d+lUZdrnPmzO9ec+UVDYXI3ezoEe2URdi9+e0CaK9YO0Tp0W9Kkoedkg==} + '@podman-desktop/api@1.13.0-202409181313-78725a6565': + resolution: {integrity: sha512-tsgjYU8bXAO9QlED6J+aUPOYXA1Nw1no6CsTTkFaZKVmL3B+jQwNxPJcVAHTz9anmqwITiEtQDpopVMg2s2buQ==} '@podman-desktop/tests-playwright@1.12.0': resolution: {integrity: sha512-Q+uE3iZBCAKqDB3uZnFItcvsd3qwepHfnHtJnijWG7ZZKBW5frf/C1Ua93looJ/795vKVsl2aaIO3oV5mJh8sg==} @@ -5816,7 +5816,7 @@ snapshots: dependencies: playwright: 1.47.2 - '@podman-desktop/api@1.12.0': {} + '@podman-desktop/api@1.13.0-202409181313-78725a6565': {} '@podman-desktop/tests-playwright@1.12.0(@playwright/test@1.47.2)(electron@32.1.2)': dependencies: