Skip to content

Commit a17798e

Browse files
authored
feat(UI): share tasks management logic between CreateService and StartRecipe (#1812)
* feat: share tasks management in CreateService and StartRecipe Signed-off-by: axel7083 <[email protected]> * fix: prettier Signed-off-by: axel7083 <[email protected]> * fix: multiple effect Signed-off-by: axel7083 <[email protected]> --------- Signed-off-by: axel7083 <[email protected]>
1 parent 3536749 commit a17798e

File tree

4 files changed

+241
-161
lines changed

4 files changed

+241
-161
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import '@testing-library/jest-dom/vitest';
20+
import { test, expect, vi } from 'vitest';
21+
import { render } from '@testing-library/svelte';
22+
import TrackedTasks from '/@/lib/progress/TrackedTasks.svelte';
23+
24+
vi.mock('../../utils/client', () => ({
25+
studioClient: {
26+
requestCancelToken: vi.fn(),
27+
},
28+
}));
29+
30+
test('empty task should not have any content', async () => {
31+
const { queryByRole } = render(TrackedTasks, {
32+
tasks: [],
33+
});
34+
35+
const status = queryByRole('status');
36+
expect(status).toBeNull();
37+
});
38+
39+
test('task without matching trackingId should not have any content', async () => {
40+
const { queryByRole } = render(TrackedTasks, {
41+
tasks: [
42+
{
43+
id: 'dummy-id',
44+
name: 'Hello World',
45+
state: 'loading',
46+
labels: {
47+
trackingId: 'dummyTrackingId',
48+
},
49+
},
50+
],
51+
trackingId: 'notMatching',
52+
});
53+
54+
const status = queryByRole('status');
55+
expect(status).toBeNull();
56+
});
57+
58+
test('task with matching trackingId should be visible', () => {
59+
const { getByRole } = render(TrackedTasks, {
60+
tasks: [
61+
{
62+
id: 'dummy-id',
63+
name: 'Hello World',
64+
state: 'loading',
65+
labels: {
66+
trackingId: 'dummyTrackingId',
67+
},
68+
},
69+
],
70+
trackingId: 'dummyTrackingId',
71+
});
72+
73+
const status = getByRole('status');
74+
expect(status).toBeInTheDocument();
75+
});
76+
77+
test('onChange should provide task with matching trackingId', () => {
78+
const onChangeMock = vi.fn();
79+
render(TrackedTasks, {
80+
tasks: [
81+
{
82+
id: 'dummy-id',
83+
name: 'Hello World',
84+
state: 'loading',
85+
labels: {
86+
trackingId: 'dummyTrackingId',
87+
},
88+
},
89+
{
90+
id: 'dummy-id-2',
91+
name: 'Hello World 2',
92+
state: 'loading',
93+
},
94+
],
95+
trackingId: 'dummyTrackingId',
96+
onChange: onChangeMock,
97+
});
98+
99+
expect(onChangeMock).toHaveBeenCalledWith([
100+
{
101+
id: 'dummy-id',
102+
name: 'Hello World',
103+
state: 'loading',
104+
labels: {
105+
trackingId: 'dummyTrackingId',
106+
},
107+
},
108+
]);
109+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<script lang="ts">
2+
import type { Task } from '@shared/src/models/ITask';
3+
import { filterByLabel } from '/@/utils/taskUtils';
4+
import TasksProgress from '/@/lib/progress/TasksProgress.svelte';
5+
6+
interface Props {
7+
trackingId?: string;
8+
tasks: Task[];
9+
class?: string;
10+
onChange?: (tasks: Task[]) => void;
11+
}
12+
let { trackingId, tasks, onChange, class: classes }: Props = $props();
13+
14+
let trackedTasks: Task[] = $derived.by(() => {
15+
if (trackingId === undefined) {
16+
return [];
17+
}
18+
19+
return filterByLabel(tasks, {
20+
trackingId: trackingId,
21+
});
22+
});
23+
24+
$effect(() => {
25+
onChange?.($state.snapshot(trackedTasks));
26+
});
27+
</script>
28+
29+
{#if trackedTasks.length > 0}
30+
<div role="status" class={classes}>
31+
<TasksProgress tasks={trackedTasks} />
32+
</div>
33+
{/if}

packages/frontend/src/pages/CreateService.svelte

Lines changed: 49 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -8,63 +8,59 @@ import { onMount } from 'svelte';
88
import { studioClient } from '/@/utils/client';
99
import { tasks } from '/@/stores/tasks';
1010
import type { Task } from '@shared/src/models/ITask';
11-
import { filterByLabel } from '/@/utils/taskUtils';
12-
import TasksProgress from '/@/lib/progress/TasksProgress.svelte';
1311
import { inferenceServers } from '/@/stores/inferenceServers';
1412
import type { ContainerProviderConnectionInfo } from '@shared/src/models/IContainerConnectionInfo';
1513
import { Button, ErrorMessage, FormPage, Input } from '@podman-desktop/ui-svelte';
1614
import ModelSelect from '../lib/select/ModelSelect.svelte';
1715
import { containerProviderConnections } from '/@/stores/containerProviderConnections';
1816
import ContainerProviderConnectionSelect from '/@/lib/select/ContainerProviderConnectionSelect.svelte';
1917
import ContainerConnectionWrapper from '/@/lib/notification/ContainerConnectionWrapper.svelte';
20-
import { get } from 'svelte/store';
18+
import TrackedTasks from '/@/lib/progress/TrackedTasks.svelte';
2119
22-
// The tracking id is a unique identifier provided by the
23-
// backend when calling requestCreateInferenceServer
24-
export let trackingId: string | undefined = undefined;
20+
interface Props {
21+
// The tracking id is a unique identifier provided by the
22+
// backend when calling requestCreateInferenceServer
23+
trackingId?: string;
24+
}
25+
26+
let { trackingId }: Props = $props();
2527
2628
// List of the models available locally
27-
let localModels: ModelInfo[];
28-
$: localModels = $modelsInfo.filter(model => model.file);
29+
let localModels: ModelInfo[] = $derived($modelsInfo.filter(model => model.file));
2930
3031
// The container provider connection to use
31-
let containerProviderConnection: ContainerProviderConnectionInfo | undefined = undefined;
32+
let containerProviderConnection: ContainerProviderConnectionInfo | undefined = $state(undefined);
3233
3334
// Filtered connections (started)
34-
let startedContainerProviderConnectionInfo: ContainerProviderConnectionInfo[] = [];
35-
$: startedContainerProviderConnectionInfo = $containerProviderConnections.filter(
36-
connection => connection.status === 'started',
35+
let startedContainerProviderConnectionInfo: ContainerProviderConnectionInfo[] = $derived(
36+
$containerProviderConnections.filter(connection => connection.status === 'started'),
3737
);
3838
39-
// Select default connection
40-
$: if (containerProviderConnection === undefined && startedContainerProviderConnectionInfo.length > 0) {
41-
containerProviderConnection = startedContainerProviderConnectionInfo[0];
42-
}
43-
4439
// The containerPort is the bind value to form input
45-
let containerPort: number | undefined = undefined;
40+
let containerPort: number | undefined = $state(undefined);
4641
// The model is the bind value to ModelSelect form
47-
let model: ModelInfo | undefined = undefined;
42+
let model: ModelInfo | undefined = $state(undefined);
4843
// If the creation of a new inference service fail
49-
let errorMsg: string | undefined = undefined;
50-
// The trackedTasks are the tasks linked to the trackingId
51-
let trackedTasks: Task[];
52-
53-
// has an error been raised
54-
let error: boolean = false;
55-
44+
let errorMsg: string | undefined = $state(undefined);
5645
// The containerId will be included in the tasks when the creation
5746
// process will be completed
58-
let containerId: string | undefined = undefined;
59-
$: available = containerId && $inferenceServers.some(server => server.container.containerId);
60-
61-
$: loading = trackingId !== undefined && !error;
62-
63-
$: {
47+
let containerId: string | undefined = $state(undefined);
48+
// available means the server is started
49+
let available: boolean = $derived(!!containerId && $inferenceServers.some(server => server.container.containerId));
50+
// loading state
51+
let loading = $derived(trackingId !== undefined && !errorMsg);
52+
53+
$effect(() => {
54+
// Select default model
6455
if (!model && localModels.length > 0) {
6556
model = localModels[0];
6657
}
67-
}
58+
59+
// Select default connection
60+
if (!containerProviderConnection && startedContainerProviderConnectionInfo.length > 0) {
61+
containerProviderConnection = startedContainerProviderConnectionInfo[0];
62+
}
63+
});
6864
6965
const onContainerPortInput = (event: Event): void => {
7066
const raw = (event.target as HTMLInputElement).value;
@@ -83,11 +79,10 @@ const submit = async (): Promise<void> => {
8379
if (containerPort === undefined) throw new Error('invalid container port');
8480
8581
try {
86-
error = false;
8782
const trackingId = await studioClient.requestCreateInferenceServer({
88-
modelsInfo: [model],
89-
port: containerPort,
90-
connection: containerProviderConnection,
83+
modelsInfo: [$state.snapshot(model)],
84+
port: $state.snapshot(containerPort),
85+
connection: $state.snapshot(containerProviderConnection),
9186
});
9287
router.location.query.set('trackingId', trackingId);
9388
} catch (err: unknown) {
@@ -107,32 +102,23 @@ const openServiceDetails = (): void => {
107102
};
108103
109104
// Utility method to filter the tasks properly based on the tracking Id
110-
const processTasks = (tasks: Task[]): void => {
111-
if (trackingId === undefined) {
112-
trackedTasks = [];
113-
return;
114-
}
115-
116-
trackedTasks = filterByLabel(tasks, {
117-
trackingId: trackingId,
118-
});
119-
105+
const processTasks = (trackedTasks: Task[]): void => {
120106
// Check for errors
121107
// hint: we do not need to display them as the TasksProgress component will
122-
error = trackedTasks.find(task => task.error)?.error !== undefined;
108+
errorMsg = trackedTasks.find(task => task.error)?.error;
123109
124110
const task: Task | undefined = trackedTasks.find(task => 'containerId' in (task.labels ?? {}));
125111
if (task === undefined) return;
126112
127113
containerId = task.labels?.['containerId'];
128114
129115
// if we re-open the page, we might need to restore the model selected
130-
populateModelFromTasks();
116+
populateModelFromTasks(trackedTasks);
131117
};
132118
133119
// This method uses the trackedTasks to restore the selected value of model
134120
// It is useful when the page has been restored
135-
function populateModelFromTasks(): void {
121+
function populateModelFromTasks(trackedTasks: Task[]): void {
136122
const task = trackedTasks.find(
137123
task => task.labels && 'model-id' in task.labels && typeof task.labels['model-id'] === 'string',
138124
);
@@ -145,26 +131,21 @@ function populateModelFromTasks(): void {
145131
model = mModel;
146132
}
147133
148-
$: if (typeof trackingId === 'string' && trackingId.length > 0) {
149-
refreshTasks();
150-
}
151-
152-
function refreshTasks(): void {
153-
processTasks(get(tasks));
154-
}
155-
156-
onMount(async () => {
157-
containerPort = await studioClient.getHostFreePort();
134+
onMount(() => {
135+
studioClient
136+
.getHostFreePort()
137+
.then(port => {
138+
containerPort = port;
139+
})
140+
.catch((err: unknown) => {
141+
console.error(err);
142+
});
158143
159144
// we might have a query parameter, then we should use it
160145
const queryModelId = router.location.query.get('model-id');
161146
if (queryModelId !== undefined && typeof queryModelId === 'string') {
162147
model = localModels.find(mModel => mModel.id === queryModelId);
163148
}
164-
165-
tasks.subscribe(tasks => {
166-
processTasks(tasks);
167-
});
168149
});
169150
170151
export function goToUpPage(): void {
@@ -189,16 +170,14 @@ export function goToUpPage(): void {
189170
<!-- warning machine resources -->
190171
{#if containerProviderConnection}
191172
<div class="mx-5">
192-
<ContainerConnectionWrapper model={model} containerProviderConnection={containerProviderConnection} />
173+
<ContainerConnectionWrapper
174+
model={$state.snapshot(model)}
175+
containerProviderConnection={$state.snapshot(containerProviderConnection)} />
193176
</div>
194177
{/if}
195178

196179
<!-- tasks tracked -->
197-
{#if trackedTasks?.length > 0}
198-
<div class="mx-5 mt-5" role="status">
199-
<TasksProgress tasks={trackedTasks} />
200-
</div>
201-
{/if}
180+
<TrackedTasks onChange={processTasks} class="mx-5 mt-5" trackingId={trackingId} tasks={$tasks} />
202181

203182
<!-- form -->
204183
<div class="bg-[var(--pd-content-card-bg)] m-5 space-y-6 px-8 sm:pb-6 xl:pb-8 rounded-lg h-fit">

0 commit comments

Comments
 (0)