22 @license
33 Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
44 */
5- import { ModelCardDeployModalEndpointPollQuery } from '../__generated__/ModelCardDeployModalEndpointPollQuery.graphql' ;
65import { ModelCardDeployModalMutation } from '../__generated__/ModelCardDeployModalMutation.graphql' ;
76import { ModelCardDeployModalQuery } from '../__generated__/ModelCardDeployModalQuery.graphql' ;
87import { useWebUINavigate } from '../hooks' ;
98import { useCurrentProjectValue } from '../hooks/useCurrentProject' ;
10- import { App , Form , Typography , theme } from 'antd' ;
9+ import { Alert , App , Form , Typography , theme } from 'antd' ;
1110import type { DefaultOptionType } from 'antd/es/select' ;
1211import {
1312 BAIButton ,
@@ -19,6 +18,7 @@ import {
1918 useProjectResourceGroups ,
2019} from 'backend.ai-ui' ;
2120import * as _ from 'lodash-es' ;
21+ import { PlusIcon } from 'lucide-react' ;
2222import React , {
2323 Suspense ,
2424 useEffect ,
@@ -28,26 +28,7 @@ import React, {
2828 useState ,
2929} from 'react' ;
3030import { useTranslation } from 'react-i18next' ;
31- import {
32- fetchQuery ,
33- graphql ,
34- useLazyLoadQuery ,
35- useMutation ,
36- useRelayEnvironment ,
37- } from 'react-relay' ;
38-
39- /**
40- * Poll-before-navigate configuration for the post-deploy handoff to the
41- * serving detail page. The v2 `deployModelCardV2` mutation creates a
42- * ModelDeployment, but the v1 `endpoint(endpoint_id: …)` projection — which
43- * the serving detail page depends on — is populated a beat later. Navigating
44- * immediately produces a page full of `-` because the v1 lookup returns
45- * null (or an endpoint with null fields) until that propagation catches up.
46- * We poll the same v1 query here so the Relay store is warm and the detail
47- * page renders complete data on first paint.
48- */
49- const ENDPOINT_POLL_INTERVAL_MS = 300 ;
50- const ENDPOINT_POLL_MAX_ATTEMPTS = 10 ;
31+ import { graphql , useLazyLoadQuery , useMutation } from 'react-relay' ;
5132
5233interface AvailablePreset {
5334 readonly id : string ;
@@ -63,21 +44,33 @@ interface ModelCardDeployModalProps {
6344 modelCardRowId ?: string ;
6445 availablePresets : ReadonlyArray < AvailablePreset > ;
6546 onDeployed : ( deploymentId : string ) => void ;
47+ /**
48+ * Called when the user wants to fall back to creating a new deployment
49+ * (via `DeploymentSettingModal`) because no preset is available. The
50+ * parent is expected to close this modal and open its own
51+ * `DeploymentSettingModal`.
52+ */
53+ onRequestCreateDeployment ?: ( ) => void ;
6654}
6755
6856type ModelCardDeployModalContentProps = Omit < ModelCardDeployModalProps , 'open' > ;
6957
7058const ModelCardDeployModalContent : React . FC <
7159 ModelCardDeployModalContentProps
72- > = ( { onClose, modelCardRowId, availablePresets, onDeployed } ) => {
60+ > = ( {
61+ onClose,
62+ modelCardRowId,
63+ availablePresets,
64+ onDeployed,
65+ onRequestCreateDeployment,
66+ } ) => {
7367 'use memo' ;
7468
7569 const { t } = useTranslation ( ) ;
7670 const { token } = theme . useToken ( ) ;
7771 const { message } = App . useApp ( ) ;
7872 const navigate = useWebUINavigate ( ) ;
7973 const { id : projectId , name : projectName } = useCurrentProjectValue ( ) ;
80- const relayEnvironment = useRelayEnvironment ( ) ;
8174
8275 // Fetch resource groups accessible to the current project. Uses the same
8376 // React Query cache as BAIProjectResourceGroupSelect below, so no duplicate
@@ -114,44 +107,6 @@ const ModelCardDeployModalContent: React.FC<
114107 }
115108 ` ) ;
116109
117- // Minimal v1 endpoint projection used only for the post-deploy poll.
118- // Kept intentionally small — we only need to confirm the endpoint is
119- // addressable before handing off to the detail page.
120- const endpointPollQuery = graphql `
121- query ModelCardDeployModalEndpointPollQuery($endpointId: UUID!) {
122- endpoint(endpoint_id: $endpointId) {
123- endpoint_id
124- name
125- status
126- }
127- }
128- ` ;
129-
130- const waitForEndpointReady = async ( endpointId : string ) : Promise < void > => {
131- for ( let attempt = 0 ; attempt < ENDPOINT_POLL_MAX_ATTEMPTS ; attempt ++ ) {
132- try {
133- const result = await fetchQuery < ModelCardDeployModalEndpointPollQuery > (
134- relayEnvironment ,
135- endpointPollQuery ,
136- { endpointId } ,
137- // Bypass the store on each attempt so we actually hit the server
138- // — the whole point of the poll is to observe fresh server state.
139- { fetchPolicy : 'network-only' } ,
140- ) . toPromise ( ) ;
141- if ( result ?. endpoint ?. endpoint_id ) {
142- return ;
143- }
144- } catch {
145- // Swallow transient errors and keep polling — on the last attempt
146- // we fall through and navigate anyway; the detail page will still
147- // render with whatever state the server can give us.
148- }
149- await new Promise ( ( resolve ) =>
150- setTimeout ( resolve , ENDPOINT_POLL_INTERVAL_MS ) ,
151- ) ;
152- }
153- } ;
154-
155110 // Build runtime variant ID → name map for preset grouping
156111 const runtimeVariantNameMap = useMemo ( ( ) => {
157112 const map = new Map < string , string > ( ) ;
@@ -233,15 +188,8 @@ const ModelCardDeployModalContent: React.FC<
233188 const deploymentId = response . deployModelCardV2 . deploymentId ;
234189 message . success ( t ( 'modelStore.DeploySuccess' ) ) ;
235190 onDeployed ( deploymentId ) ;
236- // Wait for the v1 endpoint projection to become queryable before
237- // navigating, so the serving detail page doesn't render a blank
238- // page full of `-` placeholders during the v1/v2 propagation gap.
239- waitForEndpointReady ( deploymentId )
240- . then ( ( ) => {
241- navigate ( `/serving/${ deploymentId } ` ) ;
242- resolve ( ) ;
243- } )
244- . catch ( reject ) ;
191+ navigate ( `/deployments/${ deploymentId } ` ) ;
192+ resolve ( ) ;
245193 } ,
246194 onError : ( error ) => {
247195 message . error ( error . message || t ( 'modelStore.DeployFailed' ) ) ;
@@ -276,6 +224,47 @@ const ModelCardDeployModalContent: React.FC<
276224 return null ;
277225 }
278226
227+ // Empty-state: per FR-2862, when no preset is available the modal stays
228+ // open with an inline Alert and a link to the deployment shell creation
229+ // modal (`DeploymentSettingModal`) — same UX as the `/deployments` page
230+ // "Create Deployment" entry.
231+ if ( availablePresets . length === 0 ) {
232+ return (
233+ < BAIModal
234+ title = { t ( 'modelStore.DeployModel' ) }
235+ open
236+ onCancel = { onClose }
237+ destroyOnHidden
238+ footer = { null }
239+ width = { 480 }
240+ >
241+ < Alert
242+ type = "info"
243+ showIcon
244+ title = { t ( 'deployment.NoPresetsAvailable' ) }
245+ description = { t ( 'deployment.NoPresetsAvailableDescription' ) }
246+ action = {
247+ onRequestCreateDeployment ? (
248+ < BAIButton
249+ type = "primary"
250+ icon = { < PlusIcon /> }
251+ onClick = { ( ) => {
252+ onClose ( ) ;
253+ onRequestCreateDeployment ( ) ;
254+ } }
255+ >
256+ { t ( 'deployment.OpenCreateDeploymentModal' ) }
257+ </ BAIButton >
258+ ) : undefined
259+ }
260+ />
261+ < BAIFlex justify = "end" gap = "sm" style = { { marginTop : token . marginMD } } >
262+ < BAIButton onClick = { onClose } > { t ( 'button.Cancel' ) } </ BAIButton >
263+ </ BAIFlex >
264+ </ BAIModal >
265+ ) ;
266+ }
267+
279268 // Scenario 3: selection UI — render the modal only when selection is needed.
280269 return (
281270 < BAIModal
@@ -351,6 +340,7 @@ const ModelCardDeployModal: React.FC<ModelCardDeployModalProps> = ({
351340 modelCardRowId,
352341 availablePresets,
353342 onDeployed,
343+ onRequestCreateDeployment,
354344} ) => {
355345 'use memo' ;
356346
@@ -370,6 +360,7 @@ const ModelCardDeployModal: React.FC<ModelCardDeployModalProps> = ({
370360 modelCardRowId = { modelCardRowId }
371361 availablePresets = { availablePresets }
372362 onDeployed = { onDeployed }
363+ onRequestCreateDeployment = { onRequestCreateDeployment }
373364 />
374365 </ Suspense >
375366 ) ;
0 commit comments