Skip to content

Commit c5226ab

Browse files
committed
fix(FR-2802): allow custom resource allocation when creating a new deployment
1 parent a4937e7 commit c5226ab

10 files changed

Lines changed: 346 additions & 63 deletions

File tree

data/schema.graphql

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4695,6 +4695,9 @@ input DeleteModelCardsV2Input
46954695
{
46964696
"""List of model card UUIDs to delete."""
46974697
ids: [UUID!]!
4698+
4699+
"""Added in UNRELEASED. Options for the delete operation."""
4700+
options: DeleteModelCardV2Options = null
46984701
}
46994702

47004703
"""Added in 26.4.2. Payload for bulk model card deletion."""
@@ -4705,6 +4708,16 @@ type DeleteModelCardsV2Payload
47054708
deletedCount: Int!
47064709
}
47074710

4711+
"""Added in UNRELEASED. Options for the model card delete operation."""
4712+
input DeleteModelCardV2Options
4713+
@join__type(graph: STRAWBERRY)
4714+
{
4715+
"""
4716+
If true, also soft-delete (move to trash) the model VFolder(s) associated with the deleted model card(s).
4717+
"""
4718+
deleteAssociatedVfolder: Boolean! = false
4719+
}
4720+
47084721
"""Added in 24.12.0."""
47094722
type DeleteNetwork
47104723
@join__type(graph: GRAPHENE)
@@ -11279,7 +11292,7 @@ type Mutation
1127911292
adminUpdateModelCardV2(input: UpdateModelCardV2Input!): UpdateModelCardPayloadGQL! @join__field(graph: STRAWBERRY)
1128011293

1128111294
"""Added in 26.4.2. Delete a model card (admin only)."""
11282-
adminDeleteModelCardV2(id: UUID!): DeleteModelCardPayloadGQL! @join__field(graph: STRAWBERRY)
11295+
adminDeleteModelCardV2(id: UUID!, options: DeleteModelCardV2Options = null): DeleteModelCardPayloadGQL! @join__field(graph: STRAWBERRY)
1128311296

1128411297
"""Added in 26.4.2. Delete multiple model cards (admin only)."""
1128511298
adminDeleteModelCardsV2(input: DeleteModelCardsV2Input!): DeleteModelCardsV2Payload! @join__field(graph: STRAWBERRY)

packages/backend.ai-ui/src/components/BAIText.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const BAIText: React.FC<BAITextProps> = ({
4848
keyboardWithLightBorder,
4949
...restProps
5050
}) => {
51+
'use memo';
5152
const { token } = theme.useToken();
5253
const { t } = useTranslation();
5354
const textRef = useRef<HTMLSpanElement>(null);
@@ -57,13 +58,15 @@ const BAIText: React.FC<BAITextProps> = ({
5758
const expandable = typeof ellipsis === 'object' ? ellipsis.expandable : false;
5859
const onExpand = typeof ellipsis === 'object' ? ellipsis.onExpand : undefined;
5960

61+
const ellipsisEnabled = !!ellipsis;
62+
const ellipsisRows = typeof ellipsis === 'object' ? (ellipsis.rows ?? 1) : 1;
63+
6064
useEffect(() => {
61-
if (!ellipsis || !textRef.current || isExpanded) return;
65+
if (!ellipsisEnabled || !textRef.current || isExpanded) return;
6266
const element = textRef.current;
63-
const rows = typeof ellipsis === 'object' ? ellipsis.rows || 1 : 1;
6467
const check = () => {
6568
if (!element) return;
66-
if (rows === 1) {
69+
if (ellipsisRows === 1) {
6770
setIsOverflowing(element.scrollWidth > element.clientWidth);
6871
} else {
6972
setIsOverflowing(element.scrollHeight > element.clientHeight);
@@ -73,7 +76,7 @@ const BAIText: React.FC<BAITextProps> = ({
7376
const ro = new ResizeObserver(check);
7477
ro.observe(element);
7578
return () => ro.disconnect();
76-
}, [ellipsis, children, isExpanded]);
79+
}, [ellipsisEnabled, ellipsisRows, isExpanded]);
7780

7881
const handleExpand = (e: React.MouseEvent<HTMLElement>) => {
7982
const newExpandedState = !isExpanded;

react/src/components/DeploymentLauncherPageContent.tsx

Lines changed: 180 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
*/
55
import { DeploymentLauncherPageContentPresetSummaryQuery } from '../__generated__/DeploymentLauncherPageContentPresetSummaryQuery.graphql';
66
import { DeploymentLauncherPageContent_deployment$key } from '../__generated__/DeploymentLauncherPageContent_deployment.graphql';
7+
import { convertToBinaryUnit } from '../helper';
78
import { parseCliCommand } from '../helper/parseCliCommand';
89
import {
910
mergeExtraArgs,
1011
reverseMapExtraArgs,
1112
} from '../helper/runtimeExtraArgsParser';
13+
import { useSuspendedBackendaiClient } from '../hooks';
1214
import { ResourceSlotName, useResourceSlots } from '../hooks/backendai';
1315
import { useBAISettingUserState } from '../hooks/useBAISetting';
1416
import { useCurrentProjectValue } from '../hooks/useCurrentProject';
@@ -20,6 +22,7 @@ import {
2022
getExtraArgsEnvVarName,
2123
type RuntimeParameterGroup,
2224
} from '../hooks/useRuntimeParameterSchema';
25+
import { ResourceNumbersOfSession } from '../pages/SessionLauncherPage';
2326
import ErrorBoundaryWithNullFallback from './ErrorBoundaryWithNullFallback';
2427
import ImageEnvironmentSelectFormItems, {
2528
ImageEnvironmentFormInput,
@@ -28,6 +31,9 @@ import ResourcePresetSelect from './ResourcePresetSelect';
2831
import RuntimeParameterFormSection, {
2932
RuntimeParameterValues,
3033
} from './RuntimeParameterFormSection';
34+
import ResourceAllocationFormItems, {
35+
type ResourceAllocationFormValue,
36+
} from './SessionFormItems/ResourceAllocationFormItems';
3137
import SourceCodeView from './SourceCodeView';
3238
import VFolderLazyView from './VFolderLazyView';
3339
import VFolderSelect from './VFolderSelect';
@@ -133,6 +139,11 @@ export type DeploymentLauncherFormValue = ImageEnvironmentFormInput & {
133139
// Step 3 — Resources & Replicas
134140
resourceGroup: string;
135141
resourcePresetId?: string;
142+
/** Mirrors resourcePresetId so ResourceAllocationFormItems can track custom state. */
143+
allocationPreset?: string;
144+
/** Custom resource slots — populated when resourcePresetId === 'custom'. */
145+
resource?: ResourceAllocationFormValue['resource'];
146+
enabledAutomaticShmem?: boolean;
136147
desiredReplicaCount: number;
137148
};
138149

@@ -146,6 +157,15 @@ export interface DeploymentLauncherPageContentProps {
146157
deploymentFrgmt?: DeploymentLauncherPageContent_deployment$key | null;
147158
/** Available runtime variants fetched by the parent page layout. */
148159
runtimeVariants?: ReadonlyArray<{ name: string; id: string }>;
160+
/** Available resource presets — used in edit mode to detect custom allocation. */
161+
resourcePresets?: ReadonlyArray<
162+
| {
163+
name?: string | null;
164+
resource_slots?: string | null;
165+
}
166+
| null
167+
| undefined
168+
>;
149169
/**
150170
* Optional change observer forwarded to the underlying antd `<Form>`.
151171
* Useful for parent pages that want to persist the draft state
@@ -173,6 +193,95 @@ export interface DeploymentLauncherPageContentProps {
173193
} | null>;
174194
}
175195

196+
/**
197+
* Compare revision resourceSlots against available presets to detect custom
198+
* allocation, and return the matching form fields. Returns `resourcePresetId:
199+
* 'custom'` with populated `resource.*` values when no preset matches.
200+
*/
201+
const resolveEditModeResourcePreset = (
202+
revisionSlots: ReadonlyArray<{
203+
slotName: string;
204+
quantity: string;
205+
} | null> | null,
206+
resourceOptsEntries: ReadonlyArray<{
207+
name: string;
208+
value: string;
209+
} | null> | null,
210+
presets: ReadonlyArray<
211+
| {
212+
name?: string | null;
213+
resource_slots?: string | null;
214+
}
215+
| null
216+
| undefined
217+
> | null,
218+
): Partial<DeploymentLauncherFormValue> => {
219+
if (!revisionSlots || revisionSlots.length === 0) return {};
220+
221+
const slots = revisionSlots.filter(
222+
(s): s is { slotName: string; quantity: string } => s !== null,
223+
);
224+
225+
// Normalize a slot quantity to bytes string for comparison.
226+
// Mem values may come as "4g" (preset) or raw bytes "4294967296" (revision).
227+
const toComparableBytes = (slotName: string, value: string): string => {
228+
if (slotName !== 'mem') return value;
229+
return String(convertToBinaryUnit(value, '')?.number ?? value);
230+
};
231+
232+
const matchingPreset = presets?.find((p) => {
233+
if (!p?.name || !p?.resource_slots) return false;
234+
let presetSlots: Record<string, string>;
235+
try {
236+
presetSlots = JSON.parse(p.resource_slots);
237+
} catch {
238+
return false;
239+
}
240+
const presetKeys = Object.keys(presetSlots);
241+
if (presetKeys.length !== slots.length) return false;
242+
return presetKeys.every((key) => {
243+
const revSlot = slots.find((s) => s.slotName === key);
244+
if (!revSlot) return false;
245+
return (
246+
toComparableBytes(key, String(revSlot.quantity)) ===
247+
toComparableBytes(key, String(presetSlots[key]))
248+
);
249+
});
250+
});
251+
252+
if (matchingPreset?.name) {
253+
return {
254+
resourcePresetId: matchingPreset.name,
255+
allocationPreset: matchingPreset.name,
256+
};
257+
}
258+
259+
// No preset matches — custom allocation. Reconstruct resource.* fields.
260+
const cpu = Number(slots.find((s) => s.slotName === 'cpu')?.quantity ?? 0);
261+
const rawMem = slots.find((s) => s.slotName === 'mem')?.quantity ?? '0g';
262+
// Server returns mem in bytes; convert to "Xg" format for the slider.
263+
const mem = convertToBinaryUnit(rawMem, 'g', 4)?.value ?? rawMem;
264+
const shmemEntry = resourceOptsEntries
265+
?.filter((e): e is { name: string; value: string } => e !== null)
266+
.find((e) => e.name === 'shmem');
267+
const acceleratorSlot = slots.find(
268+
(s) => s.slotName !== 'cpu' && s.slotName !== 'mem',
269+
);
270+
271+
return {
272+
resourcePresetId: 'custom',
273+
allocationPreset: 'custom',
274+
resource: {
275+
cpu,
276+
mem,
277+
shmem: shmemEntry?.value ?? '0g',
278+
accelerator: acceleratorSlot ? Number(acceleratorSlot.quantity) : 0,
279+
acceleratorType: acceleratorSlot?.slotName,
280+
},
281+
enabledAutomaticShmem: !shmemEntry,
282+
};
283+
};
284+
176285
/**
177286
* Default values applied when neither `deploymentFrgmt` nor
178287
* `preFilledModel` is provided. Also used as the merge base for
@@ -197,6 +306,9 @@ const DEFAULT_FORM_VALUES: DeploymentLauncherFormValue = {
197306
clusterSize: 1,
198307
resourceGroup: '',
199308
resourcePresetId: undefined,
309+
allocationPreset: undefined,
310+
resource: { cpu: 0, mem: '0g', shmem: '0g', accelerator: 0 },
311+
enabledAutomaticShmem: true,
200312
desiredReplicaCount: 1,
201313
environments: {
202314
environment: '',
@@ -227,6 +339,7 @@ const DeploymentLauncherPageContent: React.FC<
227339
form,
228340
deploymentFrgmt,
229341
runtimeVariants = [],
342+
resourcePresets,
230343
onValuesChange,
231344
onSubmit,
232345
isSubmitting,
@@ -237,6 +350,7 @@ const DeploymentLauncherPageContent: React.FC<
237350
const { t } = useTranslation();
238351
const { token } = theme.useToken();
239352
const screens = Grid.useBreakpoint();
353+
const baiClient = useSuspendedBackendaiClient();
240354
// Debounced CLI command parser — auto-fills port/health/mount from the
241355
// pasted command, mirroring ServiceLauncherPageContent behaviour.
242356
const { run: parseCommandWithDebounce } = useDebounceFn(
@@ -388,6 +502,16 @@ const DeploymentLauncherPageContent: React.FC<
388502
}
389503
resourceConfig {
390504
resourceGroupName
505+
resourceOpts {
506+
entries {
507+
name
508+
value
509+
}
510+
}
511+
}
512+
resourceSlots @since(version: "26.4.2") {
513+
slotName
514+
quantity
391515
}
392516
modelRuntimeConfig {
393517
runtimeVariantId
@@ -468,18 +592,26 @@ const DeploymentLauncherPageContent: React.FC<
468592
revision?.clusterConfig?.size ?? DEFAULT_FORM_VALUES.clusterSize,
469593
resourceGroup: revision?.resourceConfig?.resourceGroupName ?? '',
470594
desiredReplicaCount: deployment.replicaState.desiredReplicaCount,
471-
// TODO(needs-backend): FR-2787 — resourcePresetId is not exposed in the
472-
// ModelRevision schema, so the preset selector cannot be pre-populated in
473-
// edit mode. Once the backend exposes resourcePresetName/id on the
474-
// revision, add it here.
595+
...resolveEditModeResourcePreset(
596+
revision?.resourceSlots ?? null,
597+
revision?.resourceConfig?.resourceOpts?.entries ?? null,
598+
resourcePresets ?? null,
599+
),
475600
} satisfies Partial<DeploymentLauncherFormValue>);
476601
}
477602
return _.merge({}, DEFAULT_FORM_VALUES, {
478603
...(urlModel && { modelFolderId: urlModel }),
479604
...(urlResourceGroup && { resourceGroup: urlResourceGroup }),
480605
...(urlResourcePresetId && { resourcePresetId: urlResourcePresetId }),
481606
});
482-
}, [mode, deployment, urlModel, urlResourceGroup, urlResourcePresetId]);
607+
}, [
608+
mode,
609+
deployment,
610+
urlModel,
611+
urlResourceGroup,
612+
urlResourcePresetId,
613+
resourcePresets,
614+
]);
483615

484616
// Apply initial values to the parent-owned form instance exactly once
485617
// per mount / deployment change. Using `useEffectEvent` keeps the
@@ -866,6 +998,13 @@ const DeploymentLauncherPageContent: React.FC<
866998
display: currentStepKey === 'resources' ? 'block' : 'none',
867999
}}
8681000
>
1001+
<Form.Item
1002+
name="desiredReplicaCount"
1003+
label={t('deployment.DesiredReplicas')}
1004+
rules={[{ required: true, type: 'number', min: 1 }]}
1005+
>
1006+
<InputNumber min={1} style={{ width: '100%' }} />
1007+
</Form.Item>
8691008
<Form.Item
8701009
name="resourceGroup"
8711010
label={t('session.ResourceGroup')}
@@ -882,21 +1021,35 @@ const DeploymentLauncherPageContent: React.FC<
8821021
<Form.Item
8831022
name="resourcePresetId"
8841023
label={t('resourcePreset.ResourcePresets')}
1024+
required
8851025
>
8861026
<ResourcePresetSelect
8871027
resourceGroup={getFieldValue('resourceGroup')}
8881028
autoSelectDefault
8891029
showSearch
1030+
showCustom={baiClient._config.allowCustomResourceAllocation}
1031+
showMinimumRequired={
1032+
baiClient._config.allowCustomResourceAllocation
1033+
}
1034+
onChange={(value) => {
1035+
form.setFieldValue('allocationPreset', value);
1036+
}}
8901037
/>
8911038
</Form.Item>
8921039
)}
8931040
</Form.Item>
894-
<Form.Item
895-
name="desiredReplicaCount"
896-
label={t('deployment.DesiredReplicas')}
897-
rules={[{ required: true, type: 'number', min: 1 }]}
898-
>
899-
<InputNumber min={1} style={{ width: '100%' }} />
1041+
<Form.Item dependencies={['resourcePresetId']} noStyle>
1042+
{({ getFieldValue }) =>
1043+
getFieldValue('resourcePresetId') === 'custom' && (
1044+
<Suspense>
1045+
<ResourceAllocationFormItems
1046+
enableResourcePresets={false}
1047+
hideClusterFormItems
1048+
hideResourceGroup
1049+
/>
1050+
</Suspense>
1051+
)
1052+
}
9001053
</Form.Item>
9011054
</Card>
9021055

@@ -1301,7 +1454,22 @@ const DeploymentReviewSummary: React.FC<{
13011454
{values.resourceGroup || '-'}
13021455
</Descriptions.Item>
13031456
<Descriptions.Item label={t('resourcePreset.ResourcePresets')}>
1304-
{values.resourcePresetId ? (
1457+
{values.resourcePresetId === 'custom' ? (
1458+
<BAIFlex direction="column" gap="xs" align="start">
1459+
<Typography.Text>
1460+
{t('session.launcher.CustomAllocation')}
1461+
</Typography.Text>
1462+
{values.resource && (
1463+
<BAIFlex
1464+
direction="row"
1465+
gap="xxs"
1466+
style={{ flexWrap: 'wrap' }}
1467+
>
1468+
<ResourceNumbersOfSession resource={values.resource} />
1469+
</BAIFlex>
1470+
)}
1471+
</BAIFlex>
1472+
) : values.resourcePresetId ? (
13051473
<Suspense fallback={<>{values.resourcePresetId}</>}>
13061474
<ErrorBoundaryWithNullFallback>
13071475
<ResourcePresetReviewDisplay

0 commit comments

Comments
 (0)