diff --git a/packages/backend/src/managers/applicationManager.spec.ts b/packages/backend/src/managers/applicationManager.spec.ts index da615a1f7..369f0a86f 100644 --- a/packages/backend/src/managers/applicationManager.spec.ts +++ b/packages/backend/src/managers/applicationManager.spec.ts @@ -50,10 +50,8 @@ const mocks = vi.hoisted(() => { parseYamlFileMock: vi.fn(), listImagesMock: vi.fn(), getImageInspectMock: vi.fn(), - createPodMock: vi.fn(), createContainerMock: vi.fn(), startContainerMock: vi.fn(), - startPod: vi.fn(), inspectContainerMock: vi.fn(), logUsageMock: vi.fn(), logErrorMock: vi.fn(), @@ -69,8 +67,6 @@ const mocks = vi.hoisted(() => { startupSubscribeMock: vi.fn(), onMachineStopMock: vi.fn(), listContainersMock: vi.fn(), - stopPodMock: vi.fn(), - removePodMock: vi.fn(), performDownloadMock: vi.fn(), getTargetMock: vi.fn(), onEventDownloadMock: vi.fn(), @@ -104,16 +100,12 @@ vi.mock('@podman-desktop/api', () => ({ containerEngine: { listImages: mocks.listImagesMock, getImageInspect: mocks.getImageInspectMock, - createPod: mocks.createPodMock, createContainer: mocks.createContainerMock, startContainer: mocks.startContainerMock, - startPod: mocks.startPod, inspectContainer: mocks.inspectContainerMock, pullImage: mocks.pullImageMock, stopContainer: mocks.stopContainerMock, listContainers: mocks.listContainersMock, - stopPod: mocks.stopPodMock, - removePod: mocks.removePodMock, }, Disposable: { create: vi.fn(), @@ -146,6 +138,10 @@ const podManager = { getPodsWithLabels: vi.fn(), getHealth: vi.fn(), getPod: vi.fn(), + createPod: vi.fn(), + stopPod: vi.fn(), + removePod: vi.fn(), + startPod: vi.fn(), } as unknown as PodManager; const localRepositoryRegistry = { @@ -235,7 +231,7 @@ describe('pullApplication', () => { Id: 'id1', }, ]); - mocks.createPodMock.mockResolvedValue({ + vi.mocked(podManager.createPod).mockResolvedValue({ engineId: 'engine', Id: 'id', }); @@ -274,6 +270,7 @@ describe('pullApplication', () => { ); } test('pullApplication should clone repository and call downloadModelMain and buildImage', async () => { + vi.mocked(podManager.getPodsWithLabels).mockResolvedValue([]); mockForPullApplication({ recipeFolderExists: false, }); @@ -423,6 +420,7 @@ describe('pullApplication', () => { ); }); test('pullApplication should not download model if already on disk', async () => { + vi.mocked(podManager.getPodsWithLabels).mockResolvedValue([]); mockForPullApplication({ recipeFolderExists: true, }); @@ -723,12 +721,12 @@ describe('createPod', async () => { vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValueOnce('9000'); vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValueOnce('9001'); vi.spyOn(portsUtils, 'getPortsInfo').mockResolvedValueOnce('9002'); - mocks.createPodMock.mockResolvedValue({ + vi.mocked(podManager.createPod).mockResolvedValue({ Id: 'podId', engineId: 'engineId', }); await manager.createPod({ id: 'recipe-id' } as Recipe, { id: 'model-id' } as ModelInfo, images); - expect(mocks.createPodMock).toBeCalledWith({ + expect(podManager.createPod).toBeCalledWith({ name: expect.anything(), portmappings: [ { @@ -870,10 +868,11 @@ describe('runApplication', () => { ], } as unknown as PodInfo; test('check startPod is called and also waitContainerIsRunning for sample app', async () => { + vi.mocked(podManager.getPodsWithLabels).mockResolvedValue([]); const waitContainerIsRunningMock = vi.spyOn(manager, 'waitContainerIsRunning').mockResolvedValue(undefined); vi.spyOn(utils, 'timeout').mockResolvedValue(); await manager.runApplication(pod); - expect(mocks.startPod).toBeCalledWith(pod.engineId, pod.Id); + expect(podManager.startPod).toBeCalledWith(pod.engineId, pod.Id); expect(waitContainerIsRunningMock).toBeCalledWith(pod.engineId, { Id: 'dummyContainerId', }); @@ -1150,7 +1149,7 @@ describe('pod detection', async () => { const sendApplicationStateSpy = vi.spyOn(manager, 'notify').mockResolvedValue(); manager.adoptRunningApplications(); await new Promise(resolve => setTimeout(resolve, 10)); - expect(sendApplicationStateSpy).toHaveBeenCalledTimes(2); + expect(sendApplicationStateSpy).toHaveBeenCalledTimes(1); }); test('onPodRemove updates the applications state by removing the removed pod', async () => { @@ -1198,7 +1197,8 @@ describe('pod detection', async () => { }); }); - test('deleteApplication calls stopPod and removePod', async () => { + test('removeApplication calls stopPod and removePod', async () => { + vi.mocked(podManager.getPodsWithLabels).mockResolvedValue([]); vi.mocked(podManager.findPodByLabelsValues).mockResolvedValue({ engineId: 'engine-1', Id: 'pod-1', @@ -1207,12 +1207,13 @@ describe('pod detection', async () => { 'ai-lab-model-id': 'model-id-1', }, } as unknown as PodInfo); - await manager.deleteApplication('recipe-id-1', 'model-id-1'); - expect(mocks.stopPodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); - expect(mocks.removePodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); + await manager.removeApplication('recipe-id-1', 'model-id-1'); + expect(podManager.stopPod).toHaveBeenCalledWith('engine-1', 'pod-1'); + expect(podManager.removePod).toHaveBeenCalledWith('engine-1', 'pod-1'); }); - test('deleteApplication calls stopPod and removePod even if stopPod fails because pod already stopped', async () => { + test('removeApplication calls stopPod and removePod even if stopPod fails because pod already stopped', async () => { + vi.mocked(podManager.getPodsWithLabels).mockResolvedValue([]); vi.mocked(podManager.findPodByLabelsValues).mockResolvedValue({ engineId: 'engine-1', Id: 'pod-1', @@ -1221,10 +1222,10 @@ describe('pod detection', async () => { 'ai-lab-model-id': 'model-id-1', }, } as unknown as PodInfo); - mocks.stopPodMock.mockRejectedValue('something went wrong, pod already stopped...'); - await manager.deleteApplication('recipe-id-1', 'model-id-1'); - expect(mocks.stopPodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); - expect(mocks.removePodMock).toHaveBeenCalledWith('engine-1', 'pod-1'); + vi.mocked(podManager.stopPod).mockRejectedValue('something went wrong, pod already stopped...'); + await manager.removeApplication('recipe-id-1', 'model-id-1'); + expect(podManager.stopPod).toHaveBeenCalledWith('engine-1', 'pod-1'); + expect(podManager.removePod).toHaveBeenCalledWith('engine-1', 'pod-1'); }); test('adoptRunningApplications should check pods health', async () => { diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index 0d46f2a45..893de0413 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -194,7 +194,7 @@ export class ApplicationManager extends Publisher implements // first delete any existing pod with matching labels if (await this.hasApplicationPod(recipe.id, model.id)) { - await this.deleteApplication(recipe.id, model.id); + await this.removeApplication(recipe.id, model.id); } // create a pod containing all the containers to run the application @@ -213,7 +213,7 @@ export class ApplicationManager extends Publisher implements const task = this.taskRegistry.createTask('Starting AI App', 'loading', labels); // it starts the pod - await containerEngine.startPod(podInfo.engineId, podInfo.Id); + await this.podManager.startPod(podInfo.engineId, podInfo.Id); // check if all containers have started successfully for (const container of podInfo.Containers ?? []) { @@ -226,6 +226,8 @@ export class ApplicationManager extends Publisher implements state: 'success', name: 'AI App is running', }); + + return this.checkPodsHealth(); } async waitContainerIsRunning(engineId: string, container: PodContainerInfo): Promise { @@ -387,7 +389,7 @@ export class ApplicationManager extends Publisher implements if (appPorts.length) { labels[LABEL_APP_PORTS] = appPorts.join(','); } - const { engineId, Id } = await containerEngine.createPod({ + const { engineId, Id } = await this.podManager.createPod({ name: getRandomName(`pod-${sampleAppImageInfo.appName}`), portmappings: portmappings, labels, @@ -396,6 +398,59 @@ export class ApplicationManager extends Publisher implements return this.podManager.getPod(engineId, Id); } + /** + * Stop the pod with matching recipeId and modelId + * @param recipeId + * @param modelId + */ + async stopApplication(recipeId: string, modelId: string): Promise { + // clear existing tasks + this.clearTasks(recipeId, modelId); + + // get the application pod + const appPod = await this.getApplicationPod(recipeId, modelId); + + // if the pod is already stopped skip + if (appPod.Status === 'Exited') { + return appPod; + } + + // create a task to follow progress/error + const stoppingTask = this.taskRegistry.createTask(`Stopping AI App`, 'loading', { + 'recipe-id': recipeId, + 'model-id': modelId, + }); + + try { + await this.podManager.stopPod(appPod.engineId, appPod.Id); + + stoppingTask.state = 'success'; + stoppingTask.name = `AI App Stopped`; + } catch (err: unknown) { + stoppingTask.error = `Error removing the pod.: ${String(err)}`; + stoppingTask.name = 'Error stopping AI App'; + } finally { + this.taskRegistry.updateTask(stoppingTask); + await this.checkPodsHealth(); + } + return appPod; + } + + /** + * Utility method to start a pod using (recipeId, modelId) + * @param recipeId + * @param modelId + */ + async startApplication(recipeId: string, modelId: string): Promise { + this.clearTasks(recipeId, modelId); + const pod = await this.getApplicationPod(recipeId, modelId); + + return this.runApplication(pod, { + 'recipe-id': recipeId, + 'model-id': modelId, + }); + } + private getConfigAndFilterContainers( recipeBaseDir: string | undefined, localFolder: string, @@ -526,9 +581,6 @@ export class ApplicationManager extends Publisher implements this.podmanConnection.onPodStart((pod: PodInfo) => { this.adoptPod(pod); }); - this.podmanConnection.onPodStop((pod: PodInfo) => { - this.forgetPod(pod); - }); this.podmanConnection.onPodRemove((podId: string) => { this.forgetPodById(podId); }); @@ -576,32 +628,6 @@ export class ApplicationManager extends Publisher implements this.updateApplicationState(recipeId, modelId, state); } - private forgetPod(pod: PodInfo) { - if (!pod.Labels) { - return; - } - const recipeId = pod.Labels[LABEL_RECIPE_ID]; - const modelId = pod.Labels[LABEL_MODEL_ID]; - if (!recipeId || !modelId) { - return; - } - if (!this.#applications.has({ recipeId, modelId })) { - return; - } - this.#applications.delete({ recipeId, modelId }); - this.notify(); - - const protect = this.protectTasks.has(pod.Id); - if (!protect) { - this.taskRegistry.createTask('AI App stopped manually', 'success', { - 'recipe-id': recipeId, - 'model-id': modelId, - }); - } else { - this.protectTasks.delete(pod.Id); - } - } - private forgetPodById(podId: string) { const app = Array.from(this.#applications.values()).find(p => p.pod.Id === podId); if (!app) { @@ -671,47 +697,45 @@ export class ApplicationManager extends Publisher implements return Array.from(this.#applications.values()); } - async deleteApplication(recipeId: string, modelId: string): Promise { + private clearTasks(recipeId: string, modelId: string): void { // clear any existing status / tasks related to the pair recipeId-modelId. this.taskRegistry.deleteByLabels({ 'recipe-id': recipeId, 'model-id': modelId, }); + } - const stoppingTask = this.taskRegistry.createTask(`Stopping AI App`, 'loading', { + /** + * Method that will stop then remove a pod corresponding to the recipe and model provided + * @param recipeId + * @param modelId + */ + async removeApplication(recipeId: string, modelId: string): Promise { + const appPod = await this.stopApplication(recipeId, modelId); + + const remoteTask = this.taskRegistry.createTask(`Removing AI App`, 'loading', { 'recipe-id': recipeId, 'model-id': modelId, }); + // protect the task + this.protectTasks.add(appPod.Id); + try { - const appPod = await this.getApplicationPod(recipeId, modelId); - try { - await containerEngine.stopPod(appPod.engineId, appPod.Id); - } catch (err: unknown) { - // continue when the pod is already stopped - if (!String(err).includes('pod already stopped')) { - stoppingTask.error = 'error stopping the pod. Please try to stop and remove the pod manually'; - stoppingTask.name = 'Error stopping AI App'; - this.taskRegistry.updateTask(stoppingTask); - throw err; - } - } - this.protectTasks.add(appPod.Id); - await containerEngine.removePod(appPod.engineId, appPod.Id); + await this.podManager.removePod(appPod.engineId, appPod.Id); - stoppingTask.state = 'success'; - stoppingTask.name = `AI App stopped`; + remoteTask.state = 'success'; + remoteTask.name = `AI App Removed`; } catch (err: unknown) { - stoppingTask.error = 'error removing the pod. Please try to remove the pod manually'; - stoppingTask.name = 'Error stopping AI App'; - throw err; + remoteTask.error = 'error removing the pod. Please try to remove the pod manually'; + remoteTask.name = 'Error stopping AI App'; } finally { - this.taskRegistry.updateTask(stoppingTask); + this.taskRegistry.updateTask(remoteTask); } } async restartApplication(recipeId: string, modelId: string): Promise { const appPod = await this.getApplicationPod(recipeId, modelId); - await this.deleteApplication(recipeId, modelId); + await this.removeApplication(recipeId, modelId); const recipe = this.catalogManager.getRecipeById(recipeId); const model = this.catalogManager.getModelById(appPod.Labels[LABEL_MODEL_ID]); diff --git a/packages/backend/src/managers/recipes/PodManager.spec.ts b/packages/backend/src/managers/recipes/PodManager.spec.ts index 433328108..e2992364d 100644 --- a/packages/backend/src/managers/recipes/PodManager.spec.ts +++ b/packages/backend/src/managers/recipes/PodManager.spec.ts @@ -18,12 +18,16 @@ import { beforeEach, describe, vi, expect, test } from 'vitest'; import { PodManager } from './PodManager'; -import type { ContainerInspectInfo, PodInfo } from '@podman-desktop/api'; +import type { ContainerInspectInfo, PodCreateOptions, PodInfo } from '@podman-desktop/api'; import { containerEngine } from '@podman-desktop/api'; vi.mock('@podman-desktop/api', () => ({ containerEngine: { listPods: vi.fn(), + stopPod: vi.fn(), + removePod: vi.fn(), + startPod: vi.fn(), + createPod: vi.fn(), inspectContainer: vi.fn(), }, })); @@ -206,3 +210,27 @@ describe('getPod', () => { expect(pod.Id).toBe('pod-id-3'); }); }); + +test('stopPod should call containerEngine.stopPod', async () => { + await new PodManager().stopPod('dummy-engine-id', 'dummy-pod-id'); + expect(containerEngine.stopPod).toHaveBeenCalledWith('dummy-engine-id', 'dummy-pod-id'); +}); + +test('removePod should call containerEngine.removePod', async () => { + await new PodManager().removePod('dummy-engine-id', 'dummy-pod-id'); + expect(containerEngine.removePod).toHaveBeenCalledWith('dummy-engine-id', 'dummy-pod-id'); +}); + +test('startPod should call containerEngine.startPod', async () => { + await new PodManager().startPod('dummy-engine-id', 'dummy-pod-id'); + expect(containerEngine.startPod).toHaveBeenCalledWith('dummy-engine-id', 'dummy-pod-id'); +}); + +test('createPod should call containerEngine.createPod', async () => { + const options: PodCreateOptions = { + name: 'dummy-name', + portmappings: [], + }; + await new PodManager().createPod(options); + expect(containerEngine.createPod).toHaveBeenCalledWith(options); +}); diff --git a/packages/backend/src/managers/recipes/PodManager.ts b/packages/backend/src/managers/recipes/PodManager.ts index 16a8baf6d..d9172dcf5 100644 --- a/packages/backend/src/managers/recipes/PodManager.ts +++ b/packages/backend/src/managers/recipes/PodManager.ts @@ -15,7 +15,7 @@ * * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { Disposable, PodInfo } from '@podman-desktop/api'; +import type { Disposable, PodCreateOptions, PodInfo } from '@podman-desktop/api'; import { containerEngine } from '@podman-desktop/api'; import type { PodHealth } from '@shared/src/models/IApplicationState'; import { getPodHealth } from '../../utils/podsUtils'; @@ -81,4 +81,20 @@ export class PodManager implements Disposable { if (!result) throw new Error(`pod with engineId ${engineId} and Id ${Id} cannot be found.`); return result; } + + async stopPod(engineId: string, id: string): Promise { + return containerEngine.stopPod(engineId, id); + } + + async removePod(engineId: string, id: string): Promise { + return containerEngine.removePod(engineId, id); + } + + async startPod(engineId: string, id: string): Promise { + return containerEngine.startPod(engineId, id); + } + + async createPod(podOptions: PodCreateOptions): Promise<{ engineId: string; Id: string }> { + return containerEngine.createPod(podOptions); + } } diff --git a/packages/backend/src/studio-api-impl.spec.ts b/packages/backend/src/studio-api-impl.spec.ts index 3ea73130c..e3cd5ec53 100644 --- a/packages/backend/src/studio-api-impl.spec.ts +++ b/packages/backend/src/studio-api-impl.spec.ts @@ -127,7 +127,7 @@ beforeEach(async () => { // Creating StudioApiImpl studioApiImpl = new StudioApiImpl( { - deleteApplication: mocks.deleteApplicationMock, + removeApplication: mocks.deleteApplicationMock, } as unknown as ApplicationManager, catalogManager, {} as ModelsManager, diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index e00e85fbc..7f6694b1b 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -271,6 +271,18 @@ export class StudioApiImpl implements StudioAPI { return this.applicationManager.getApplicationsState(); } + async requestStartApplication(recipeId: string, modelId: string): Promise { + this.applicationManager.startApplication(recipeId, modelId).catch((err: unknown) => { + console.error('Something went wrong while trying to start application', err); + }); + } + + async requestStopApplication(recipeId: string, modelId: string): Promise { + this.applicationManager.stopApplication(recipeId, modelId).catch((err: unknown) => { + console.error('Something went wrong while trying to stop application', err); + }); + } + async requestRemoveApplication(recipeId: string, modelId: string): Promise { const recipe = this.catalogManager.getRecipeById(recipeId); // Do not wait on the promise as the api would probably timeout before the user answer. @@ -282,7 +294,7 @@ export class StudioApiImpl implements StudioAPI { ) .then((result: string | undefined) => { if (result === 'Confirm') { - this.applicationManager.deleteApplication(recipeId, modelId).catch((err: unknown) => { + this.applicationManager.removeApplication(recipeId, modelId).catch((err: unknown) => { console.error(`error deleting AI App's pod: ${String(err)}`); podmanDesktopApi.window .showErrorMessage( diff --git a/packages/frontend/src/lib/ApplicationActions.spec.ts b/packages/frontend/src/lib/ApplicationActions.spec.ts index c6d830fda..a4d50db4c 100644 --- a/packages/frontend/src/lib/ApplicationActions.spec.ts +++ b/packages/frontend/src/lib/ApplicationActions.spec.ts @@ -17,7 +17,7 @@ ***********************************************************************/ import '@testing-library/jest-dom/vitest'; -import { expect, test, vi, beforeEach } from 'vitest'; +import { expect, test, vi, beforeEach, describe } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import { studioClient } from '../utils/client'; @@ -27,6 +27,8 @@ import { router } from 'tinro'; vi.mock('../utils/client', async () => ({ studioClient: { + requestStopApplication: vi.fn(), + requestStartApplication: vi.fn(), requestRemoveApplication: vi.fn(), requestRestartApplication: vi.fn(), requestOpenApplication: vi.fn(), @@ -35,6 +37,8 @@ vi.mock('../utils/client', async () => ({ beforeEach(() => { vi.resetAllMocks(); + vi.mocked(studioClient.requestStopApplication).mockResolvedValue(undefined); + vi.mocked(studioClient.requestStartApplication).mockResolvedValue(undefined); vi.mocked(studioClient.requestRemoveApplication).mockResolvedValue(undefined); vi.mocked(studioClient.requestRestartApplication).mockResolvedValue(undefined); vi.mocked(studioClient.requestOpenApplication).mockResolvedValue(undefined); @@ -43,7 +47,9 @@ beforeEach(() => { test('deletion action should call requestRemoveApplication', async () => { render(ApplicationActions, { object: { - pod: {}, + pod: { + Containers: [], + }, } as unknown as ApplicationState, recipeId: 'dummy-recipe-id', modelId: 'dummy-model-id', @@ -56,26 +62,101 @@ test('deletion action should call requestRemoveApplication', async () => { expect(studioClient.requestRemoveApplication).toHaveBeenCalledWith('dummy-recipe-id', 'dummy-model-id'); }); -test('open action should call requestOpenApplication', async () => { - render(ApplicationActions, { - object: { - pod: {}, - } as unknown as ApplicationState, - recipeId: 'dummy-recipe-id', - modelId: 'dummy-model-id', +describe('open action', () => { + test('open action should call requestOpenApplication', async () => { + render(ApplicationActions, { + object: { + pod: { + Containers: [ + { + Status: 'running', + }, + ], + }, + } as unknown as ApplicationState, + recipeId: 'dummy-recipe-id', + modelId: 'dummy-model-id', + }); + + const openBtn = screen.getByTitle('Open AI App'); + expect(openBtn).toBeVisible(); + + await fireEvent.click(openBtn); + expect(studioClient.requestOpenApplication).toHaveBeenCalledWith('dummy-recipe-id', 'dummy-model-id'); }); - const openBtn = screen.getByTitle('Open AI App'); - expect(openBtn).toBeVisible(); + test('open action should not be visible when all container exited', async () => { + render(ApplicationActions, { + object: { + pod: { + Containers: [ + { + Status: 'exited', + }, + ], + }, + } as unknown as ApplicationState, + recipeId: 'dummy-recipe-id', + modelId: 'dummy-model-id', + }); + + const openBtn = screen.queryByTitle('Open AI App'); + expect(openBtn).toBeNull(); + }); +}); - await fireEvent.click(openBtn); - expect(studioClient.requestOpenApplication).toHaveBeenCalledWith('dummy-recipe-id', 'dummy-model-id'); +describe('start action', () => { + test('start action should be visible when all container exited', async () => { + render(ApplicationActions, { + object: { + pod: { + Containers: [ + { + Status: 'exited', + }, + ], + }, + } as unknown as ApplicationState, + recipeId: 'dummy-recipe-id', + modelId: 'dummy-model-id', + }); + + const startBtn = screen.getByTitle('Start AI App'); + expect(startBtn).toBeDefined(); + + await fireEvent.click(startBtn); + expect(studioClient.requestStartApplication).toHaveBeenCalledWith('dummy-recipe-id', 'dummy-model-id'); + }); + + test('start action should be hidden when one container is not exited', async () => { + render(ApplicationActions, { + object: { + pod: { + Containers: [ + { + Status: 'exited', + }, + { + Status: 'running', + }, + ], + }, + } as unknown as ApplicationState, + recipeId: 'dummy-recipe-id', + modelId: 'dummy-model-id', + }); + + const startBtn = screen.queryByTitle('Start AI App'); + expect(startBtn).toBeNull(); + }); }); test('restart action should call requestRestartApplication', async () => { render(ApplicationActions, { object: { - pod: {}, + pod: { + Containers: [], + }, } as unknown as ApplicationState, recipeId: 'dummy-recipe-id', modelId: 'dummy-model-id', @@ -92,7 +173,9 @@ test('open recipe action should redirect to recipe page', async () => { const routerSpy = vi.spyOn(router, 'goto'); render(ApplicationActions, { object: { - pod: {}, + pod: { + Containers: [], + }, } as unknown as ApplicationState, recipeId: 'dummy-recipe-id', modelId: 'dummy-model-id', @@ -109,7 +192,9 @@ test('open recipe action should redirect to recipe page', async () => { test('open recipe action should not be visible by default', async () => { render(ApplicationActions, { object: { - pod: {}, + pod: { + Containers: [], + }, } as unknown as ApplicationState, recipeId: 'dummy-recipe-id', modelId: 'dummy-model-id', diff --git a/packages/frontend/src/lib/ApplicationActions.svelte b/packages/frontend/src/lib/ApplicationActions.svelte index d03d56207..641277463 100644 --- a/packages/frontend/src/lib/ApplicationActions.svelte +++ b/packages/frontend/src/lib/ApplicationActions.svelte @@ -1,11 +1,19 @@ {#if object?.pod !== undefined} - - - + {#if exited} + + {:else} + + + {/if} {/if} diff --git a/packages/shared/src/StudioAPI.ts b/packages/shared/src/StudioAPI.ts index 3ac553e8c..5c153ca2f 100644 --- a/packages/shared/src/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -34,7 +34,16 @@ import type { ContainerConnectionInfo } from './models/IContainerConnectionInfo' export abstract class StudioAPI { abstract ping(): Promise; abstract getCatalog(): Promise; + + // Application related methods abstract pullApplication(recipeId: string, modelId: string): Promise; + abstract requestStopApplication(recipeId: string, modelId: string): Promise; + abstract requestStartApplication(recipeId: string, modelId: string): Promise; + abstract requestRemoveApplication(recipeId: string, modelId: string): Promise; + abstract requestRestartApplication(recipeId: string, modelId: string): Promise; + abstract requestOpenApplication(recipeId: string, modelId: string): Promise; + abstract getApplicationsState(): Promise; + abstract openURL(url: string): Promise; abstract openFile(file: string, recipeId?: string): Promise; abstract openDialog(options?: OpenDialogOptions): Promise; @@ -55,11 +64,6 @@ export abstract class StudioAPI { abstract navigateToResources(): Promise; abstract navigateToEditConnectionProvider(connectionName: string): Promise; - abstract getApplicationsState(): Promise; - abstract requestRemoveApplication(recipeId: string, modelId: string): Promise; - abstract requestRestartApplication(recipeId: string, modelId: string): Promise; - abstract requestOpenApplication(recipeId: string, modelId: string): Promise; - abstract telemetryLogUsage(eventName: string, data?: Record): Promise; abstract telemetryLogError(eventName: string, data?: Record): Promise;