diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index ddabbd5c1..bac207936 100644 --- a/packages/backend/src/managers/applicationManager.spec.ts +++ b/packages/backend/src/managers/applicationManager.spec.ts @@ -699,7 +699,9 @@ describe('createPod', async () => { ); test('throw an error if there is no sample image', async () => { const images = [imageInfo2]; - await expect(manager.createPod({ id: 'recipe-id' } as Recipe, images)).rejects.toThrowError('no sample app found'); + await expect( + manager.createPod({ id: 'recipe-id' } as Recipe, { id: 'model-id' } as ModelInfo, images), + ).rejects.toThrowError('no sample app found'); }); test('call createPod with sample app exposed port', async () => { const images = [imageInfo1, imageInfo2]; @@ -709,7 +711,7 @@ describe('createPod', async () => { Id: 'podId', engineId: 'engineId', }); - await manager.createPod({ id: 'recipe-id' } as Recipe, images); + await manager.createPod({ id: 'recipe-id' } as Recipe, { id: 'model-id' } as ModelInfo, images); expect(mocks.createPodMock).toBeCalledWith({ name: 'name', portmappings: [ @@ -730,6 +732,7 @@ describe('createPod', async () => { ], labels: { 'ai-studio-recipe-id': 'recipe-id', + 'ai-studio-model-id': 'model-id', }, }); }); @@ -759,7 +762,13 @@ describe('createApplicationPod', () => { test('throw if createPod fails', async () => { vi.spyOn(manager, 'createPod').mockRejectedValue('error createPod'); await expect( - manager.createApplicationPod({ id: 'recipe-id' } as Recipe, images, 'path', taskUtils), + manager.createApplicationPod( + { id: 'recipe-id' } as Recipe, + { id: 'model-id' } as ModelInfo, + images, + 'path', + taskUtils, + ), ).rejects.toThrowError('error createPod'); expect(setTaskMock).toBeCalledWith({ error: 'Something went wrong while creating pod: error createPod', @@ -778,7 +787,13 @@ describe('createApplicationPod', () => { const createAndAddContainersToPodMock = vi .spyOn(manager, 'createAndAddContainersToPod') .mockImplementation((_pod: PodInfo, _images: ImageInfo[], _modelPath: string) => Promise.resolve([])); - await manager.createApplicationPod({ id: 'recipe-id' } as Recipe, images, 'path', taskUtils); + await manager.createApplicationPod( + { id: 'recipe-id' } as Recipe, + { id: 'model-id' } as ModelInfo, + images, + 'path', + taskUtils, + ); expect(createAndAddContainersToPodMock).toBeCalledWith(pod, images, 'path'); expect(setTaskMock).toBeCalledWith({ id: 'id', @@ -795,7 +810,13 @@ describe('createApplicationPod', () => { vi.spyOn(manager, 'createPod').mockResolvedValue(pod); vi.spyOn(manager, 'createAndAddContainersToPod').mockRejectedValue('error'); await expect(() => - manager.createApplicationPod({ id: 'recipe-id' } as Recipe, images, 'path', taskUtils), + manager.createApplicationPod( + { id: 'recipe-id' } as Recipe, + { id: 'model-id' } as ModelInfo, + images, + 'path', + taskUtils, + ), ).rejects.toThrowError('error'); expect(setTaskMock).toHaveBeenLastCalledWith({ id: 'id', diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index 3f52ae72c..f94c1e7c0 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -32,6 +32,7 @@ import type { ModelsManager } from './modelsManager'; import { getPortsInfo } from '../utils/ports'; import { goarch } from '../utils/arch'; import { getDurationSecondsSince, isEndpointAlive, timeout } from '../utils/utils'; +import { LABEL_MODEL_ID } from './playground'; export const LABEL_RECIPE_ID = 'ai-studio-recipe-id'; @@ -102,7 +103,7 @@ export class ApplicationManager { ); // create a pod containing all the containers to run the application - const podInfo = await this.createApplicationPod(recipe, images, modelPath, taskUtil); + const podInfo = await this.createApplicationPod(recipe, model, images, modelPath, taskUtil); await this.runApplication(podInfo, taskUtil); taskUtil.setStatus('running'); @@ -183,6 +184,7 @@ export class ApplicationManager { async createApplicationPod( recipe: Recipe, + model: ModelInfo, images: ImageInfo[], modelPath: string, taskUtil: RecipeStatusUtils, @@ -190,7 +192,7 @@ export class ApplicationManager { // create empty pod let podInfo: PodInfo; try { - podInfo = await this.createPod(recipe, images); + podInfo = await this.createPod(recipe, model, images); } catch (e) { console.error('error when creating pod', e); taskUtil.setTask({ @@ -301,7 +303,7 @@ export class ApplicationManager { return containers; } - async createPod(recipe: Recipe, images: ImageInfo[]): Promise { + async createPod(recipe: Recipe, model: ModelInfo, images: ImageInfo[]): Promise { // find the exposed port of the sample app so we can open its ports on the new pod const sampleAppImageInfo = images.find(image => !image.modelService); if (!sampleAppImageInfo) { @@ -331,6 +333,7 @@ export class ApplicationManager { portmappings: portmappings, labels: { [LABEL_RECIPE_ID]: recipe.id, + [LABEL_MODEL_ID]: model.id, }, }); return { diff --git a/packages/backend/src/managers/environmentManager.spec.ts b/packages/backend/src/managers/environmentManager.spec.ts index fdcfcb5d5..143b6a067 100644 --- a/packages/backend/src/managers/environmentManager.spec.ts +++ b/packages/backend/src/managers/environmentManager.spec.ts @@ -27,6 +27,8 @@ import type { podStopHandle, startupHandle, } from './podmanConnection'; +import type { ApplicationManager } from './applicationManager'; +import type { CatalogManager } from './catalogManager'; let manager: EnvironmentManager; @@ -82,6 +84,8 @@ beforeEach(() => { startupSubscribe: mocks.startupSubscribe, onMachineStop: mocks.onMachineStop, } as unknown as PodmanConnection, + {} as ApplicationManager, + {} as CatalogManager, ); }); diff --git a/packages/backend/src/managers/environmentManager.ts b/packages/backend/src/managers/environmentManager.ts index 1dcbb44e8..7e260c301 100644 --- a/packages/backend/src/managers/environmentManager.ts +++ b/packages/backend/src/managers/environmentManager.ts @@ -18,9 +18,12 @@ import { type PodInfo, type Webview, containerEngine } from '@podman-desktop/api'; import type { PodmanConnection } from './podmanConnection'; +import type { ApplicationManager } from './applicationManager'; import { LABEL_RECIPE_ID } from './applicationManager'; import { MSG_ENVIRONMENTS_STATE_UPDATE } from '@shared/Messages'; import type { EnvironmentState, EnvironmentStatus } from '@shared/src/models/IEnvironmentState'; +import type { CatalogManager } from './catalogManager'; +import { LABEL_MODEL_ID } from './playground'; /** * An Environment is represented as a Pod, independently on how it has been created (by applicationManager or any other manager) @@ -32,6 +35,8 @@ export class EnvironmentManager { constructor( private webview: Webview, private podmanConnection: PodmanConnection, + private applicationManager: ApplicationManager, + private catalogManager: CatalogManager, ) { this.#environments = new Map(); } @@ -157,6 +162,19 @@ export class EnvironmentManager { } } + async restartEnvironment(recipeId: string) { + const envPod = await this.getEnvironmentPod(recipeId); + await this.deleteEnvironment(recipeId); + try { + const recipe = this.catalogManager.getRecipeById(recipeId); + const model = this.catalogManager.getModelById(envPod.Labels[LABEL_MODEL_ID]); + await this.applicationManager.pullApplication(recipe, model); + } catch (err: unknown) { + this.setEnvironmentStatus(recipeId, 'unknown'); + throw err; + } + } + async getEnvironmentPod(recipeId: string): Promise { if (!containerEngine.listPods || !containerEngine.stopPod || !containerEngine.removePod) { // TODO(feloy) this check can be safely removed when podman desktop 1.8 is released diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index b59496a12..aac163c52 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -181,6 +181,32 @@ export class StudioApiImpl implements StudioAPI { }); } + async requestRestartEnvironment(recipeId: string): Promise { + const recipe = this.catalogManager.getRecipeById(recipeId); + // Do not wait on the promise as the api would probably timeout before the user answer. + podmanDesktopApi.window + .showWarningMessage( + `Restart the environment "${recipe.name}"? This will delete the containers running the application and model, rebuild the images with the current sources, and restart the containers.`, + 'Confirm', + 'Cancel', + ) + .then((result: string) => { + if (result === 'Confirm') { + this.environmentManager.restartEnvironment(recipeId).catch((err: unknown) => { + console.error(`error restarting environment: ${String(err)}`); + podmanDesktopApi.window + .showErrorMessage(`Error restarting the environment "${recipe.name}"`) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + }); + } + }) + .catch((err: unknown) => { + console.error(`Something went wrong with confirmation modals`, err); + }); + } + async telemetryLogUsage( eventName: string, data?: Record, diff --git a/packages/backend/src/studio.ts b/packages/backend/src/studio.ts index be7d091aa..6852dc4e2 100644 --- a/packages/backend/src/studio.ts +++ b/packages/backend/src/studio.ts @@ -136,7 +136,12 @@ export class Studio { this.modelsManager, this.telemetry, ); - const envManager = new EnvironmentManager(this.#panel.webview, podmanConnection); + const envManager = new EnvironmentManager( + this.#panel.webview, + podmanConnection, + applicationManager, + this.catalogManager, + ); this.#panel.onDidChangeViewState((e: WebviewPanelOnDidChangeViewStateEvent) => { this.telemetry.logUsage(e.webviewPanel.visible ? 'opened' : 'closed'); diff --git a/packages/frontend/src/lib/table/environment/ColumnActions.svelte b/packages/frontend/src/lib/table/environment/ColumnActions.svelte index 0dc3ddb0d..d9500453e 100644 --- a/packages/frontend/src/lib/table/environment/ColumnActions.svelte +++ b/packages/frontend/src/lib/table/environment/ColumnActions.svelte @@ -1,8 +1,9 @@ - deleteEnvironment()} - title="Delete Environment" - inProgress={object.status === 'stopping' || object.status === 'removing'} -/> + +{#if object.status === 'stopping' || object.status === 'removing'} +
+{:else} + deleteEnvironment()} + title="Delete Environment" + /> + + restartEnvironment()} + title="Restart Environment" + /> +{/if} + diff --git a/packages/shared/src/StudioAPI.ts b/packages/shared/src/StudioAPI.ts index 5fe79ef02..328d488cc 100644 --- a/packages/shared/src/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -31,6 +31,7 @@ export abstract class StudioAPI { abstract navigateToContainer(containerId: string): Promise; abstract getEnvironmentsState(): Promise; abstract requestRemoveEnvironment(recipeId: string): Promise; + abstract requestRestartEnvironment(recipeId: string): Promise; abstract telemetryLogUsage(eventName: string, data?: Record): Promise; abstract telemetryLogError(eventName: string, data?: Record): Promise;