Skip to content

Commit

Permalink
feat: service creation form (containers#529)
Browse files Browse the repository at this point in the history
* feat: creating service creation form

Signed-off-by: axel7083 <[email protected]>

* fix: hide service name

Signed-off-by: axel7083 <[email protected]>

* fix: equal check missing

Signed-off-by: axel7083 <[email protected]>

* feat: adding loading animation

Signed-off-by: axel7083 <[email protected]>

* test: ensuring ModelColumnAction works as expected

Signed-off-by: axel7083 <[email protected]>

* test: ensuring CreateService works as expected

Signed-off-by: axel7083 <[email protected]>

* fix: linter&prettier

Signed-off-by: axel7083 <[email protected]>

* feat: using getHostFreePort()

Signed-off-by: axel7083 <[email protected]>

* fix: tests

Signed-off-by: axel7083 <[email protected]>

* fix: remove name and container image input

Signed-off-by: axel7083 <[email protected]>

* fix: unit tests

Signed-off-by: axel7083 <[email protected]>

---------

Signed-off-by: axel7083 <[email protected]>
  • Loading branch information
axel7083 authored Mar 15, 2024
1 parent a0bf692 commit f399ab9
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 3 deletions.
8 changes: 7 additions & 1 deletion packages/frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Recipe from '/@/pages/Recipe.svelte';
import Model from './pages/Model.svelte';
import { onMount } from 'svelte';
import { getRouterState } from '/@/utils/client';
import CreateService from '/@/pages/CreateService.svelte';
import Services from '/@/pages/InferenceServers.svelte';
import ServiceDetails from '/@/pages/InferenceServerDetails.svelte';
Expand Down Expand Up @@ -60,6 +61,7 @@ onMount(() => {
<Route path="/models/*" breadcrumb="Models">
<Models />
</Route>

<Route path="/model/:id/*" breadcrumb="Model Details" let:meta>
<Model modelId="{meta.params.id}" />
</Route>
Expand All @@ -69,7 +71,11 @@ onMount(() => {
</Route>

<Route path="/service/:id/*" breadcrumb="Service Details" let:meta>
<ServiceDetails containerId="{meta.params.id}" />
{#if meta.params.id === 'create'}
<CreateService />
{:else}
<ServiceDetails containerId="{meta.params.id}" />
{/if}
</Route>
</div>
</main>
Expand Down
37 changes: 37 additions & 0 deletions packages/frontend/src/lib/table/model/ModelColumnAction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { test, expect, vi, beforeEach } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import type { ModelInfo } from '@shared/src/models/IModelInfo';
import ModelColumnActions from '/@/lib/table/model/ModelColumnActions.svelte';
import { router } from 'tinro';

const mocks = vi.hoisted(() => ({
requestRemoveLocalModel: vi.fn(),
Expand Down Expand Up @@ -71,6 +72,9 @@ test('Expect folder and delete button in document', async () => {
const deleteBtn = screen.getByTitle('Delete Model');
expect(deleteBtn).toBeInTheDocument();

const rocketBtn = screen.getByTitle('Create Model Service');
expect(rocketBtn).toBeInTheDocument();

const downloadBtn = screen.queryByTitle('Download Model');
expect(downloadBtn).toBeNull();
});
Expand All @@ -94,6 +98,9 @@ test('Expect download button in document', async () => {
const deleteBtn = screen.queryByTitle('Delete Model');
expect(deleteBtn).toBeNull();

const rocketBtn = screen.queryByTitle('Create Model Service');
expect(rocketBtn).toBeNull();

const downloadBtn = screen.getByTitle('Download Model');
expect(downloadBtn).toBeInTheDocument();
});
Expand All @@ -119,3 +126,33 @@ test('Expect downloadModel to be call on click', async () => {
expect(mocks.downloadModel).toHaveBeenCalledWith('my-model');
});
});

test('Expect router to be called when rocket icon clicked', async () => {
const gotoMock = vi.spyOn(router, 'goto');
const replaceMock = vi.spyOn(router.location.query, 'replace');

const object: ModelInfo = {
id: 'my-model',
description: '',
hw: '',
license: '',
name: '',
registry: '',
url: '',
file: {
file: 'file',
creation: new Date(),
size: 1000,
path: 'path',
},
};
render(ModelColumnActions, { object });

const rocketBtn = screen.getByTitle('Create Model Service');

await fireEvent.click(rocketBtn);
await waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith('/service/create');
expect(replaceMock).toHaveBeenCalledWith({ 'model-id': 'my-model' });
});
});
13 changes: 12 additions & 1 deletion packages/frontend/src/lib/table/model/ModelColumnActions.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script lang="ts">
import type { ModelInfo } from '@shared/src/models/IModelInfo';
import { faDownload, faTrash } from '@fortawesome/free-solid-svg-icons';
import { faDownload, faRocket, faTrash } from '@fortawesome/free-solid-svg-icons';
import { faFolderOpen } from '@fortawesome/free-solid-svg-icons';
import ListItemButtonIcon from '../../button/ListItemButtonIcon.svelte';
import { studioClient } from '/@/utils/client';
import { router } from 'tinro';
export let object: ModelInfo;
function deleteModel() {
Expand All @@ -25,9 +26,19 @@ function downloadModel() {
});
}
}
function createModelService() {
router.goto('/service/create');
router.location.query.replace({ 'model-id': object.id });
}
</script>

{#if object.file !== undefined}
<ListItemButtonIcon
icon="{faRocket}"
title="Create Model Service"
enabled="{!object.state}"
onClick="{() => createModelService()}" />
<ListItemButtonIcon
icon="{faFolderOpen}"
onClick="{() => openModelFolder()}"
Expand Down
100 changes: 100 additions & 0 deletions packages/frontend/src/pages/CreateService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**********************************************************************
* 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 { vi, beforeEach, test, expect } from 'vitest';
import { studioClient } from '/@/utils/client';
import { render, screen, fireEvent } from '@testing-library/svelte';
import CreateService from '/@/pages/CreateService.svelte';

const mocks = vi.hoisted(() => {
return {
modelsInfoSubscribeMock: vi.fn(),
modelsInfoQueriesMock: {
subscribe: (f: (msg: any) => void) => {
f(mocks.modelsInfoSubscribeMock());
return () => {};
},
},
};
});

vi.mock('../stores/modelsInfo', async () => {
return {
modelsInfo: mocks.modelsInfoQueriesMock,
};
});

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

beforeEach(() => {
vi.resetAllMocks();
mocks.modelsInfoSubscribeMock.mockReturnValue([]);
vi.mocked(studioClient.createInferenceServer).mockResolvedValue(undefined);
vi.mocked(studioClient.getHostFreePort).mockResolvedValue(8888);
});

test('create button should be disabled when no model id provided', async () => {
render(CreateService);

await vi.waitFor(() => {
const createBtn = screen.getByTitle('Create service');
expect(createBtn).toBeDefined();
expect(createBtn.attributes.getNamedItem('disabled')).toBeTruthy();
});
});

test('expect error message to be displayed when no model locally', async () => {
render(CreateService);

await vi.waitFor(() => {
const alert = screen.getByRole('alert');
expect(alert).toBeDefined();
});
});

test('expect error message to be hidden when models locally', () => {
mocks.modelsInfoSubscribeMock.mockReturnValue([{ id: 'random', file: true }]);
render(CreateService);

const alert = screen.queryByRole('alert');
expect(alert).toBeNull();
});

test('button click should call createInferenceServer', async () => {
mocks.modelsInfoSubscribeMock.mockReturnValue([{ id: 'random', file: true }]);
render(CreateService);

let createBtn: HTMLElement | undefined = undefined;
await vi.waitFor(() => {
createBtn = screen.getByTitle('Create service');
expect(createBtn).toBeDefined();
});

if (createBtn === undefined) throw new Error('createBtn undefined');

await fireEvent.click(createBtn);
expect(vi.mocked(studioClient.createInferenceServer)).toHaveBeenCalledWith({
modelsInfo: [{ id: 'random', file: true }],
port: 8888,
});
});
116 changes: 116 additions & 0 deletions packages/frontend/src/pages/CreateService.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<script lang="ts">
import NavPage from '/@/lib/NavPage.svelte';
import Button from '/@/lib/button/Button.svelte';
import { faExclamationCircle, faPlus, faPlusCircle } from '@fortawesome/free-solid-svg-icons';
import { modelsInfo } from '/@/stores/modelsInfo';
import type { ModelInfo } from '@shared/src/models/IModelInfo';
import Fa from 'svelte-fa';
import { router } from 'tinro';
import { onMount } from 'svelte';
import { studioClient } from '/@/utils/client';
let submitting: boolean = false;
let localModels: ModelInfo[];
$: localModels = $modelsInfo.filter(model => model.file);
let containerPort: number | undefined = undefined;
let modelId: string | undefined = undefined;
const onContainerPortInput = (event: Event): void => {
const raw = (event.target as HTMLInputElement).value;
try {
containerPort = parseInt(raw);
} catch (e: unknown) {
console.warn('invalid value for container port', e);
containerPort = 8888;
}
};
const submit = () => {
const model: ModelInfo | undefined = localModels.find(model => model.id === modelId);
if (model === undefined) throw new Error('model id not valid.');
if (containerPort === undefined) throw new Error('invalid container port');
// disable submit button
submitting = true;
studioClient
.createInferenceServer({
modelsInfo: [model],
port: containerPort,
})
.catch(err => {
console.error('Something wrong while trying to create the inference server.', err);
})
.finally(() => {
submitting = false;
router.goto('/services');
});
};
const openModelsPage = () => {
router.goto(`/models`);
};
onMount(async () => {
containerPort = await studioClient.getHostFreePort();
const queryModelId = router.location.query.get('model-id');
if (queryModelId !== undefined && typeof queryModelId === 'string') {
modelId = queryModelId;
}
});
</script>

<NavPage icon="{faPlus}" title="Creating Model service" searchEnabled="{false}" loading="{containerPort === undefined}">
<svelte:fragment slot="content">
<div class="bg-charcoal-800 m-5 pt-5 space-y-6 px-8 sm:pb-6 xl:pb-8 rounded-lg w-full h-fit">
<div class="w-full">
<!-- model input -->
<label for="model" class="pt-4 block mb-2 text-sm font-bold text-gray-400">Model</label>
<select
required
bind:value="{modelId}"
id="providerChoice"
class="border text-sm rounded-lg w-full focus:ring-purple-500 focus:border-purple-500 block p-2.5 bg-charcoal-900 border-charcoal-900 placeholder-gray-700 text-white"
name="providerChoice">
{#each localModels as model}
<option class="my-1" value="{model.id}">{model.name}</option>
{/each}
</select>
{#if localModels.length === 0}
<div class="text-red-500 p-1 flex flex-row items-center">
<Fa size="1.1x" class="cursor-pointer text-red-500" icon="{faExclamationCircle}" />
<div role="alert" aria-label="Error Message Content" class="ml-2">
You don't have any models downloaded. You can download them in <a
href="{'javascript:void(0);'}"
class="underline"
title="Models page"
on:click="{openModelsPage}">models page</a
>.
</div>
</div>
{/if}
<!-- container port input -->
<label for="containerPort" class="pt-4 block mb-2 text-sm font-bold text-gray-400">Container port</label>
<input
type="number"
bind:value="{containerPort}"
on:input="{onContainerPortInput}"
class="w-full p-2 outline-none text-sm bg-charcoal-600 rounded-sm text-gray-700 placeholder-gray-700"
placeholder="8888"
name="containerPort"
required />
</div>
<footer>
<div class="w-full flex flex-col">
<Button
title="Create service"
inProgress="{submitting}"
on:click="{submit}"
disabled="{!modelId}"
icon="{faPlusCircle}">
Create service
</Button>
</div>
</footer>
</div>
</svelte:fragment>
</NavPage>
2 changes: 1 addition & 1 deletion packages/frontend/src/pages/Models.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const columns: Column<ModelInfo>[] = [
new Column<ModelInfo>('HW Compat', { width: '1fr', renderer: ModelColumnHw }),
new Column<ModelInfo>('Registry', { width: '2fr', renderer: ModelColumnRegistry }),
new Column<ModelInfo>('License', { width: '2fr', renderer: ModelColumnLicense }),
new Column<ModelInfo>('Actions', { align: 'right', width: '80px', renderer: ModelColumnActions }),
new Column<ModelInfo>('Actions', { align: 'right', width: '120px', renderer: ModelColumnActions }),
];
const row = new Row<ModelInfo>({});
Expand Down

0 comments on commit f399ab9

Please sign in to comment.