Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: revamp recipes list page #1199

Merged
merged 4 commits into from
Jun 13, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 23 additions & 16 deletions packages/backend/src/managers/applicationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,21 +129,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
}
}

/**
* This method will execute the following tasks
* - git clone
* - git checkout
* - register local repository
* - download models
* - upload models
* - build containers
* - create pod
*
* @param recipe
* @param model
* @private
*/
private async initApplication(recipe: Recipe, model: ModelInfo): Promise<PodInfo> {
public async cloneApplication(recipe: Recipe, labels?: { [key: string]: string }): Promise<void> {
const localFolder = path.join(this.appUserDirectory, recipe.id);

// clone the recipe repository on the local folder
Expand All @@ -153,8 +139,8 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
targetDirectory: localFolder,
};
await this.doCheckout(gitCloneInfo, {
...labels,
'recipe-id': recipe.id,
'model-id': model.id,
});

this.localRepositories.register({
Expand All @@ -164,6 +150,27 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
'recipe-id': recipe.id,
},
});
}

/**
* This method will execute the following tasks
* - git clone
* - git checkout
* - register local repository
* - download models
* - upload models
* - build containers
* - create pod
*
* @param recipe
* @param model
* @private
*/
private async initApplication(recipe: Recipe, model: ModelInfo): Promise<PodInfo> {
const localFolder = path.join(this.appUserDirectory, recipe.id);

// clone the application
await this.cloneApplication(recipe, { 'model-id': model.id });

// load and parse the recipe configuration file and filter containers based on architecture, gpu accelerator
// and backend (that define which model supports)
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/studio-api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ export class StudioApiImpl implements StudioAPI {
return await podmanDesktopApi.window.showOpenDialog(options);
}

async cloneApplication(recipeId: string): Promise<void> {
const recipe = this.catalogManager.getRecipes().find(recipe => recipe.id === recipeId);
if (!recipe) throw new Error(`recipe with if ${recipeId} not found`);

return this.applicationManager.cloneApplication(recipe);
}

async pullApplication(recipeId: string, modelId: string): Promise<void> {
const recipe = this.catalogManager.getRecipes().find(recipe => recipe.id === recipeId);
if (!recipe) throw new Error(`recipe with if ${recipeId} not found`);
Expand Down
69 changes: 57 additions & 12 deletions packages/frontend/src/lib/RecipeCard.svelte
Original file line number Diff line number Diff line change
@@ -1,18 +1,63 @@
<script lang="ts">
import { getIcon } from '/@/utils/categoriesUtils.js';
import Card from '/@/lib/Card.svelte';
import type { Recipe } from '@shared/src/models/IRecipe';

export let background: string = 'bg-charcoal-600';
export let backgroundHover: string = 'hover:bg-charcoal-500';
import { router } from 'tinro';
import { faArrowUpRightFromSquare, faFolder } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa';
import { localRepositories } from '../stores/localRepositories';
import { findLocalRepositoryByRecipeId } from '/@/utils/localRepositoriesUtils';
import type { LocalRepository } from '@shared/src/models/ILocalRepository';
import RecipeStatus from '/@/lib/RecipeStatus.svelte';

export let recipe: Recipe;
export let displayDescription: boolean = true;

let localPath: LocalRepository | undefined = undefined;
$: localPath = findLocalRepositoryByRecipeId($localRepositories, recipe.id);
</script>

<Card
href="/recipe/{recipe.id}"
title="{recipe.name}"
description="{displayDescription ? recipe.description : ''}"
icon="{getIcon(recipe.icon)}"
classes="{background} {backgroundHover} flex-grow p-4 h-full" />
<div class="no-underline">
<div
class="bg-[var(--pd-content-card-bg)] hover:bg-[var(--pd-content-card-hover-bg)] flex-grow p-4 h-full rounded-md flex-nowrap flex flex-col"
role="region">
<!-- body -->
<div class="flex flex-row text-base grow">
<!-- left column -->
<div class="flex flex-col grow">
<span class="">{recipe.name}</span>
<span class="text-sm text-gray-700">{recipe.description}</span>
</div>

<!-- right column -->
<div>
<RecipeStatus recipe="{recipe}" localRepository="{localPath}" />
</div>
</div>

{#if localPath}
<div
class="bg-charcoal-600 max-w-full rounded-md p-2 mb-2 flex flex-row w-min h-min text-xs text-nowrap items-center">
<Fa class="mr-2" icon="{faFolder}" />
<span class="overflow-x-hidden text-ellipsis max-w-full">
{localPath.path}
</span>
</div>
{/if}

<!-- footer -->
<div class="flex flex-row">
<!-- version -->
<div class="flex-grow">
{#if recipe.ref}
<span>{recipe.ref}</span>
{/if}
</div>

<!-- more details -->
<button on:click="{() => router.goto(`/recipe/${recipe.id}`)}">
<div class="flex flex-row items-center">
<Fa class="mr-2" icon="{faArrowUpRightFromSquare}" />
<span> More details </span>
</div>
</button>
</div>
</div>
</div>
72 changes: 72 additions & 0 deletions packages/frontend/src/lib/RecipeStatus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**********************************************************************
* 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 { fireEvent, render, screen } from '@testing-library/svelte';
import { expect, test, vi } from 'vitest';
import RecipeStatus from '/@/lib/RecipeStatus.svelte';
import type { Recipe } from '@shared/src/models/IRecipe';
import { studioClient } from '/@/utils/client';

vi.mock('../utils/client', async () => ({
studioClient: {
cloneApplication: vi.fn(),
},
}));

test('download icon should be visible when localPath is undefined', async () => {
render(RecipeStatus, {
recipe: {} as unknown as Recipe,
localRepository: undefined,
});

const icon = screen.getByLabelText('download icon');
expect(icon).toBeDefined();
});

test('chevron down icon should be visible when localPath is defined', async () => {
render(RecipeStatus, {
recipe: {} as unknown as Recipe,
localRepository: {
labels: {},
path: 'random-path',
sourcePath: 'random-source-path',
},
});

const icon = screen.getByLabelText('chevron down icon');
expect(icon).toBeDefined();
});

test('click on download icon should call cloneApplication', async () => {
vi.mocked(studioClient.cloneApplication).mockResolvedValue(undefined);

render(RecipeStatus, {
recipe: {
id: 'dummy-recipe-id',
} as unknown as Recipe,
localRepository: undefined,
});

const button = screen.getByRole('button');
await fireEvent.click(button);

await vi.waitFor(() => {
expect(studioClient.cloneApplication).toHaveBeenCalledWith('dummy-recipe-id');
});
});
46 changes: 46 additions & 0 deletions packages/frontend/src/lib/RecipeStatus.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Recipe } from '@shared/src/models/IRecipe';
import Fa from 'svelte-fa';
import type { LocalRepository } from '@shared/src/models/ILocalRepository';
import { faCircleChevronDown, faDownload } from '@fortawesome/free-solid-svg-icons';
import { Spinner } from '@podman-desktop/ui-svelte';
import { studioClient } from '/@/utils/client';

export let recipe: Recipe;
export let localRepository: LocalRepository | undefined;

let loading: boolean = false;

function onClick(): void {
if (loading || localRepository) return;
loading = true;

studioClient
.cloneApplication(recipe.id)
.catch((err: unknown) => {
console.error(err);
})
.finally(() => {
loading = false;
});
}
</script>

{#key loading}
<button
on:click="{onClick}"
disabled="{loading}"
class="border-2 justify-center relative rounded border-dustypurple-700 text-dustypurple-700 hover:bg-charcoal-800 hover:text-dustypurple-600 w-10 p-2 text-center cursor-pointer flex flex-row">
{#if localRepository}
<div aria-label="chevron down icon">
<Fa size="sm" icon="{faCircleChevronDown}" />
</div>
{:else if loading}
<Spinner size="1em" />
{:else}
<div aria-label="download icon">
<Fa size="sm" icon="{faDownload}" />
</div>
{/if}
</button>
{/key}
15 changes: 14 additions & 1 deletion packages/frontend/src/lib/RecipesCard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,22 @@

import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import { expect, test, vi } from 'vitest';
import RecipesCard from '/@/lib/RecipesCard.svelte';

vi.mock('../utils/client', async () => ({
studioClient: {},
}));

vi.mock('../stores/localRepositories', () => ({
localRepositories: {
subscribe: (f: (msg: any) => void) => {
f([]);
return () => {};
},
},
}));

test('recipes card without recipes should display empty message', async () => {
render(RecipesCard, {
recipes: [],
Expand Down
9 changes: 2 additions & 7 deletions packages/frontend/src/lib/RecipesCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,16 @@ import type { Recipe } from '@shared/src/models/IRecipe';

export let category: Category;
export let recipes: Recipe[];

export let primaryBackground: string = 'bg-charcoal-800';
export let secondaryBackground: string = 'bg-charcoal-700';

export let displayDescription: boolean = true;
</script>

<Card title="{category.name}" classes="{primaryBackground} {$$props.class} text-sm font-medium mt-4">
<Card title="{category.name}" classes="{$$props.class} text-sm font-medium mt-4">
<div slot="content" class="w-full">
{#if recipes.length === 0}
<div class="text-xs text-gray-400 mt-2">There is no recipe in this category for now ! Come back later</div>
{/if}
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mt-4">
{#each recipes as recipe}
<RecipeCard recipe="{recipe}" background="{secondaryBackground}" displayDescription="{displayDescription}" />
<RecipeCard recipe="{recipe}" />
{/each}
</div>
</div>
Expand Down
13 changes: 13 additions & 0 deletions packages/frontend/src/pages/Recipes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ vi.mock('/@/stores/catalog', async () => {
};
});

vi.mock('../utils/client', async () => ({
studioClient: {},
}));

vi.mock('../stores/localRepositories', () => ({
localRepositories: {
subscribe: (f: (msg: any) => void) => {
f([]);
return () => {};
},
},
}));

beforeEach(() => {
vi.resetAllMocks();
const catalog: ApplicationCatalog = {
Expand Down
7 changes: 1 addition & 6 deletions packages/frontend/src/pages/Recipes.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,7 @@ onMount(() => {
<div class="min-w-full min-h-full flex-1">
<div class="px-5 space-y-5">
{#each groups.entries() as [category, recipes]}
<RecipesCard
category="{category}"
recipes="{recipes}"
primaryBackground=""
secondaryBackground="bg-charcoal-800"
displayCategory="{false}" />
<RecipesCard category="{category}" recipes="{recipes}" />
{/each}
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/StudioAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export abstract class StudioAPI {
abstract getCatalog(): Promise<ApplicationCatalog>;

// Application related methods
/**
* Clone a recipe
* @param recipeId
*/
abstract cloneApplication(recipeId: string): Promise<void>;
abstract pullApplication(recipeId: string, modelId: string): Promise<void>;
abstract requestStopApplication(recipeId: string, modelId: string): Promise<void>;
abstract requestStartApplication(recipeId: string, modelId: string): Promise<void>;
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/models/ILocalRepository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export interface LocalRepository {
// recipeFolder
path: string;
// recipeFolder + basedir
sourcePath: string;
labels: { [id: string]: string };
}