diff --git a/web/packages/studio/src/hooks/useModelDeploymentStatus/index.test.tsx b/web/packages/studio/src/hooks/useModelDeploymentStatus/index.test.tsx new file mode 100644 index 0000000000..94ede0e0ad --- /dev/null +++ b/web/packages/studio/src/hooks/useModelDeploymentStatus/index.test.tsx @@ -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 = {}) => + ({ 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', + }); + }); +}); diff --git a/web/packages/studio/src/hooks/useModelDeploymentStatus/index.ts b/web/packages/studio/src/hooks/useModelDeploymentStatus/index.ts index 74f9a5be74..9a656e0a7c 100644 --- a/web/packages/studio/src/hooks/useModelDeploymentStatus/index.ts +++ b/web/packages/studio/src/hooks/useModelDeploymentStatus/index.ts @@ -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, }; diff --git a/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.tsx b/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.tsx index 92174d97e2..feaa56f341 100644 --- a/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.tsx +++ b/web/packages/studio/src/routes/WorkspaceBaseModelsRoute/index.tsx @@ -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'; @@ -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}` @@ -285,6 +292,7 @@ export const WorkspaceBaseModelsRoute: FC = () => { ), }} model={selectedModel ?? undefined} + deployment={selectedModelDeployment} showCustomizationDetails={CUSTOMIZER_ENABLED} defaultTab={tabFromUrl} onTabChange={(tab) =>