Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { ModelEntity } from '@nemo/sdk/generated/platform/schema';
import { useModelDeploymentStatus } from '@studio/hooks/useModelDeploymentStatus';
import { renderHook } from '@testing-library/react';

const mockGetProvider = vi.fn();
const mockGetLatestDeployment = vi.fn();

vi.mock('@nemo/sdk/generated/platform/model-providers', () => ({
useModelsGetProvider: (...args: unknown[]) => mockGetProvider(...args),
}));

vi.mock('@nemo/sdk/generated/platform/model-deployments', () => ({
useModelsGetLatestDeployment: (...args: unknown[]) => mockGetLatestDeployment(...args),
}));

const buildModel = (overrides: Partial<ModelEntity> = {}) =>
({ id: 'model-1', name: 'my-model', workspace: 'ws', ...overrides }) as ModelEntity;

beforeEach(() => {
vi.clearAllMocks();
mockGetProvider.mockReturnValue({ data: undefined, isLoading: false });
mockGetLatestDeployment.mockReturnValue({ data: undefined, isLoading: false });
});

describe('useModelDeploymentStatus', () => {
it('returns nulls when the model has no providers', () => {
const { result } = renderHook(() => useModelDeploymentStatus(buildModel()));

expect(result.current.status).toBeNull();
expect(result.current.deployment).toBeNull();
expect(result.current.deploymentRef).toBeNull();
expect(result.current.isLoading).toBe(false);
});

it('resolves the deployment through model_providers -> provider -> deployment', () => {
mockGetProvider.mockReturnValue({
data: { model_deployment_id: 'ws/my-deployment' },
isLoading: false,
});
mockGetLatestDeployment.mockReturnValue({
data: { name: 'my-deployment', workspace: 'ws', status: 'ready' },
isLoading: false,
});

const { result } = renderHook(() =>
useModelDeploymentStatus(buildModel({ model_providers: ['ws/my-provider'] }))
);

expect(result.current.status).toBe('ready');
expect(result.current.deployment).toEqual({
name: 'my-deployment',
workspace: 'ws',
status: 'ready',
});
expect(result.current.deploymentRef).toEqual({ workspace: 'ws', name: 'my-deployment' });
});

it('exposes deploymentRef before the deployment itself has loaded, so a link can render early', () => {
mockGetProvider.mockReturnValue({
data: { model_deployment_id: 'ws/my-deployment' },
isLoading: false,
});
mockGetLatestDeployment.mockReturnValue({ data: undefined, isLoading: true });

const { result } = renderHook(() =>
useModelDeploymentStatus(buildModel({ model_providers: ['ws/my-provider'] }))
);

expect(result.current.deployment).toBeNull();
expect(result.current.status).toBeNull();
expect(result.current.deploymentRef).toEqual({ workspace: 'ws', name: 'my-deployment' });
expect(result.current.isLoading).toBe(true);
});

it('keeps deploymentRef null when the provider names no deployment', () => {
mockGetProvider.mockReturnValue({ data: { model_deployment_id: undefined }, isLoading: false });

const { result } = renderHook(() =>
useModelDeploymentStatus(buildModel({ model_providers: ['ws/my-provider'] }))
);

expect(result.current.deploymentRef).toBeNull();
expect(result.current.deployment).toBeNull();
});

it('resolves a cross-workspace deployment reference to its own workspace', () => {
mockGetProvider.mockReturnValue({
data: { model_deployment_id: 'other-ws/shared-deployment' },
isLoading: false,
});
mockGetLatestDeployment.mockReturnValue({
data: { name: 'shared-deployment', workspace: 'other-ws', status: 'ready' },
isLoading: false,
});

const { result } = renderHook(() =>
useModelDeploymentStatus(buildModel({ model_providers: ['ws/my-provider'] }))
);

expect(result.current.deploymentRef).toEqual({
workspace: 'other-ws',
name: 'shared-deployment',
});
});
});
18 changes: 18 additions & 0 deletions web/packages/studio/src/hooks/useModelDeploymentStatus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ export function useModelDeploymentStatus(model: ModelEntity | undefined) {
return {
/** The resolved deployment status, or null if no deployment found */
status,
/**
* The resolved deployment itself, or null if none was found.
*
* Callers that need to link to the deployment (rather than merely report
* that one exists) need its name, which only this object carries.
*/
deployment: deployment ?? null,
/**
* `{ workspace, name }` for the resolved deployment, or null.
*
* Mirrors the arguments the deployment query above was issued with, so a
* caller building a link cannot drift from what was actually fetched.
* Non-null whenever the provider named a deployment, even if that
* deployment has not loaded yet.
*/
deploymentRef: deploymentParts?.name
? { workspace: deploymentParts.workspace ?? workspace, name: deploymentParts.name }
: null,
/** Whether the provider/deployment chain is still loading */
isLoading,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { CustomizeModelButton } from '@studio/components/dataViews/CustomModelsD
import { ModelPanel, ModelPanelTab } from '@studio/components/sidePanels/ModelPanels/ModelPanel';
import { VirtualizedCardGrid } from '@studio/components/VirtualizedCardGrid';
import { CUSTOMIZER_ENABLED } from '@studio/constants/environment';
import { useModelDeploymentStatus } from '@studio/hooks/useModelDeploymentStatus';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs';
import { getWorkspaceBaseModelsRoute } from '@studio/routes/utils';
Expand Down Expand Up @@ -257,6 +258,12 @@ export const WorkspaceBaseModelsRoute: FC = () => {
/** Base models can only be deleted when no `model_providers` entries reference them. */
const allowModelDelete = !!selectedModel && !(selectedModel.model_providers?.length ?? 0);

// Feeds the panel's Status row, which had no source until the hook started
// returning the deployment itself rather than only its status.
const { deployment: selectedModelDeployment } = useModelDeploymentStatus(
selectedModel ?? undefined
);

const sortSelectValue = dataViewState.sorting.state[0]
? dataViewState.sorting.state[0].desc
? `-${dataViewState.sorting.state[0].id}`
Expand Down Expand Up @@ -285,6 +292,7 @@ export const WorkspaceBaseModelsRoute: FC = () => {
),
}}
model={selectedModel ?? undefined}
deployment={selectedModelDeployment}
showCustomizationDetails={CUSTOMIZER_ENABLED}
defaultTab={tabFromUrl}
onTabChange={(tab) =>
Expand Down