From c62e450f66ca43fa2f36d785e72a6756be39be70 Mon Sep 17 00:00:00 2001 From: Alex Ray Date: Tue, 8 Sep 2026 13:08:27 -0700 Subject: [PATCH] feat(studio): return the resolved deployment from useModelDeploymentStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook already walked model_providers -> ModelProvider -> ModelDeployment and fetched the whole deployment object, then returned only `{ status, isLoading }`. Nothing in Studio could name the deployment serving a model, because the one place that knew threw it away. That gap is why the only "where is it deployed" affordance that ships today (FilesetMetadataPanel) links to the deployments *list* and leaves the user to find the row, and why `getWorkspaceDeploymentDetailsRoute` — which needs a deployment name — has no caller reachable from a model. Adds `deployment` and `deploymentRef` to the return value. No new request: both come from data the hook had already resolved. `deploymentRef` mirrors the arguments the deployment query was issued with, so a caller building a link cannot drift from what was actually fetched, and it is populated as soon as the provider names a deployment — before the deployment itself has loaded — so a link can render without waiting. Both existing consumers (useModelChatAvailability, DeploymentIndicator) destructure only `status` and `isLoading`, so they are unaffected. Wires up ModelPanel's `deployment` prop from the Base Models route. The prop has existed since the panel was written and drives a status dot and a Status row, but no caller ever passed it, so that UI was dead. It now renders. Adds the hook's first test file, covering the no-providers case, the full walk, the provider-named-no-deployment case, a cross-workspace deployment reference, and the render-early contract for `deploymentRef`. Signed-off-by: Alex Ray --- .../useModelDeploymentStatus/index.test.tsx | 108 ++++++++++++++++++ .../hooks/useModelDeploymentStatus/index.ts | 18 +++ .../routes/WorkspaceBaseModelsRoute/index.tsx | 8 ++ 3 files changed, 134 insertions(+) create mode 100644 web/packages/studio/src/hooks/useModelDeploymentStatus/index.test.tsx 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) =>