Skip to content

Commit

Permalink
feat: adding Services page (#508)
Browse files Browse the repository at this point in the history
* feat: adding Services page

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

* test: ensuring new components work as expected

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

* revert packages/backend/src/utils/inferenceUtils.ts

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

* fix: rename file

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

* fix: adding catch block for inference server operations

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

* test: adding resolve values

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

* fix: rendering process of status component

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

---------

Signed-off-by: axel7083 <[email protected]>
  • Loading branch information
axel7083 authored Mar 13, 2024
1 parent d9dc209 commit d97f3fe
Show file tree
Hide file tree
Showing 10 changed files with 381 additions and 1 deletion.
4 changes: 3 additions & 1 deletion packages/backend/src/managers/inference/inferenceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class InferenceManager extends Publisher<InferenceServer[]> implements Di
status: result.State.Status === 'running' ? 'running' : 'stopped',
health: result.State.Health,
});
this.notify();
})
.catch((err: unknown) => {
console.error(
Expand Down Expand Up @@ -320,7 +321,7 @@ export class InferenceManager extends Publisher<InferenceServer[]> implements Di
this.#servers.set(server.container.containerId, {
...server,
status: 'running',
health: undefined,
health: undefined, // remove existing health checks
});
this.notify();
} catch (error: unknown) {
Expand All @@ -347,6 +348,7 @@ export class InferenceManager extends Publisher<InferenceServer[]> implements Di
this.#servers.set(server.container.containerId, {
...server,
status: 'stopped',
health: undefined, // remove existing health checks
});
this.notify();
} catch (error: unknown) {
Expand Down
5 changes: 5 additions & 0 deletions 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 Services from '/@/pages/InferenceServers.svelte';
router.mode.hash();
Expand Down Expand Up @@ -61,6 +62,10 @@ onMount(() => {
<Route path="/model/:id/*" breadcrumb="Model Details" let:meta>
<Model modelId="{meta.params.id}" />
</Route>

<Route path="/services/*" breadcrumb="Services">
<Services />
</Route>
</div>
</main>
</Route>
2 changes: 2 additions & 0 deletions packages/frontend/src/lib/Navigation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export let meta: TinroRouteMeta;

<SettingsNavItem title="Models" href="/models" bind:meta="{meta}" />

<SettingsNavItem title="Services" href="/services" bind:meta="{meta}" />

<SettingsNavItem title="Preferences" href="/preferences" bind:meta="{meta}" />
</div>
</nav>
97 changes: 97 additions & 0 deletions packages/frontend/src/lib/table/service/ServiceAction.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**********************************************************************
* 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 { expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import ServiceAction from './ServiceAction.svelte';
import { studioClient } from '/@/utils/client';

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

beforeEach(() => {
vi.resetAllMocks();
vi.mocked(studioClient.startInferenceServer).mockResolvedValue(undefined);
vi.mocked(studioClient.stopInferenceServer).mockResolvedValue(undefined);
});

test('should display stop button when status running', async () => {
render(ServiceAction, {
object: {
health: undefined,
models: [],
connection: { port: 8888 },
status: 'running',
container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' },
},
});

const stopBtn = screen.getByTitle('Stop container');
expect(stopBtn).toBeDefined();
});

test('should display start button when status stopped', async () => {
render(ServiceAction, {
object: {
health: undefined,
models: [],
connection: { port: 8888 },
status: 'stopped',
container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' },
},
});

const startBtn = screen.getByTitle('Start container');
expect(startBtn).toBeDefined();
});

test('should call stopInferenceServer when click stop', async () => {
render(ServiceAction, {
object: {
health: undefined,
models: [],
connection: { port: 8888 },
status: 'running',
container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' },
},
});

const stopBtn = screen.getByTitle('Stop container');
await fireEvent.click(stopBtn);
expect(studioClient.stopInferenceServer).toHaveBeenCalledWith('dummyContainerId');
});

test('should call startInferenceServer when click start', async () => {
render(ServiceAction, {
object: {
health: undefined,
models: [],
connection: { port: 8888 },
status: 'stopped',
container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' },
},
});

const startBtn = screen.getByTitle('Start container');
await fireEvent.click(startBtn);
expect(studioClient.startInferenceServer).toHaveBeenCalledWith('dummyContainerId');
});
25 changes: 25 additions & 0 deletions packages/frontend/src/lib/table/service/ServiceAction.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script lang="ts">
import type { InferenceServer } from '@shared/src/models/IInference';
import { studioClient } from '/@/utils/client';
import { faPlay, faStop, faTrash } from '@fortawesome/free-solid-svg-icons';
import ListItemButtonIcon from '/@/lib/button/ListItemButtonIcon.svelte';
export let object: InferenceServer;
function stopInferenceServer() {
studioClient.stopInferenceServer(object.container.containerId).catch((err: unknown) => {
console.error('Something went wrong while trying to stop inference server', err);
});
}
function startInferenceServer() {
studioClient.startInferenceServer(object.container.containerId).catch((err: unknown) => {
console.error('Something went wrong while trying to start inference server', err);
});
}
</script>

{#if object.status === 'running'}
<ListItemButtonIcon icon="{faStop}" onClick="{stopInferenceServer}" title="Stop container" />
{:else}
<ListItemButtonIcon icon="{faPlay}" onClick="{startInferenceServer}" title="Start container" />
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script lang="ts">
import type { InferenceServer } from '@shared/src/models/IInference';
export let object: InferenceServer;
</script>

<button class="text-sm text-gray-700">
{object.container.containerId}
</button>
91 changes: 91 additions & 0 deletions packages/frontend/src/lib/table/service/ServiceStatus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**********************************************************************
* 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 { expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import ServiceStatus from './ServiceStatus.svelte';
import { studioClient } from '/@/utils/client';

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

test('undefined health should display a spinner', async () => {
render(ServiceStatus, {
object: {
health: undefined,
models: [],
connection: { port: 8888 },
status: 'running',
container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' },
},
});

const img = screen.getByRole('img');
expect(img).toBeDefined();

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

test('defined health should not display a spinner', async () => {
render(ServiceStatus, {
object: {
health: {
Status: 'starting',
Log: [],
FailingStreak: 1,
},
models: [],
connection: { port: 8888 },
status: 'running',
container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' },
},
});

const img = screen.queryByRole('img');
expect(img).toBeNull();

const button = screen.getByRole('button');
expect(button).toBeDefined();
});

test('click on status icon should redirect to container', async () => {
render(ServiceStatus, {
object: {
health: {
Status: 'starting',
Log: [],
FailingStreak: 1,
},
models: [],
connection: { port: 8888 },
status: 'running',
container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' },
},
});
// Get button and click on it
const button = screen.getByRole('button');
await fireEvent.click(button);

await waitFor(() => {
expect(studioClient.navigateToContainer).toHaveBeenCalledWith('dummyContainerId');
});
});
39 changes: 39 additions & 0 deletions packages/frontend/src/lib/table/service/ServiceStatus.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script lang="ts">
import type { InferenceServer } from '@shared/src/models/IInference';
import StatusIcon from '/@/lib/StatusIcon.svelte';
import ContainerIcon from '/@/lib/images/ContainerIcon.svelte';
import { studioClient } from '/@/utils/client';
import Spinner from '/@/lib/button/Spinner.svelte';
export let object: InferenceServer;
function navigateToContainer() {
studioClient.navigateToContainer(object.container.containerId);
}
function getStatus(): 'RUNNING' | 'STARTING' | 'DEGRADED' | '' {
if (object.status === 'stopped') {
return '';
}
switch (object.health?.Status) {
case 'healthy':
return 'RUNNING';
case 'unhealthy':
return 'DEGRADED';
case 'starting':
return 'STARTING';
default:
return '';
}
}
</script>

{#key object.status}
{#if object.health === undefined && object.status !== 'stopped'}
<Spinner />
{:else}
<button on:click="{navigateToContainer}">
<StatusIcon status="{getStatus()}" icon="{ContainerIcon}" />
</button>
{/if}
{/key}
75 changes: 75 additions & 0 deletions packages/frontend/src/pages/InferenceServers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**********************************************************************
* 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 { vi, test, expect, beforeEach } from 'vitest';
import { screen, render } from '@testing-library/svelte';
import InferenceServers from '/@/pages/InferenceServers.svelte';
import type { InferenceServer } from '@shared/src/models/IInference';

const mocks = vi.hoisted(() => ({
inferenceServersSubscribeMock: vi.fn(),
inferenceServersMock: {
subscribe: (f: (msg: any) => void) => {
f(mocks.inferenceServersSubscribeMock());
return () => {};
},
},
}));
vi.mock('../stores/inferenceServers', async () => {
return {
inferenceServers: mocks.inferenceServersMock,
};
});

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

beforeEach(() => {
vi.clearAllMocks();
mocks.inferenceServersSubscribeMock.mockReturnValue([]);
});

test('no inference servers should display a status message', async () => {
render(InferenceServers);
const status = screen.getByRole('status');
expect(status).toBeInTheDocument();
expect(status.textContent).toBe('There is no services running for now.');

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

test('store with inference server should display the table', async () => {
mocks.inferenceServersSubscribeMock.mockReturnValue([
{
health: undefined,
models: [],
connection: { port: 8888 },
status: 'running',
container: { containerId: 'dummyContainerId', engineId: 'dummyEngineId' },
},
] as InferenceServer[]);
render(InferenceServers);

const table = screen.getByRole('table');
expect(table).toBeInTheDocument();
});
Loading

0 comments on commit d97f3fe

Please sign in to comment.