style: gpu instance service
This commit is contained in:
@@ -165,7 +165,7 @@ export default {
|
||||
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
|
||||
'clusters.systemDefaultContainerRegistry.tip':
|
||||
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
|
||||
'clusters.k8sOptions.title': 'K8s Deployment Options',
|
||||
'clusters.k8sOptions.title': 'Kubernetes Deployment Options',
|
||||
'clusters.imageCredentials.title': 'Image Credentials',
|
||||
'clusters.imageCredentials.add': 'Add Credential',
|
||||
'clusters.imageCredentials.registry': 'Registry',
|
||||
@@ -180,7 +180,7 @@ export default {
|
||||
'clusters.namespace.title': 'Namespace',
|
||||
'clusters.namespace.tip':
|
||||
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||
'clusters.gpuInstances.title': 'GPU Instances',
|
||||
'clusters.gpuInstances.title': 'GPU Instance Service',
|
||||
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||
'clusters.gpuInstances.staticAddress': 'Static Access Address',
|
||||
'clusters.gpuInstances.staticAddress.tip':
|
||||
|
||||
@@ -165,7 +165,7 @@ export default {
|
||||
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
|
||||
'clusters.systemDefaultContainerRegistry.tip':
|
||||
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
|
||||
'clusters.k8sOptions.title': 'K8s Deployment Options',
|
||||
'clusters.k8sOptions.title': 'Kubernetes Deployment Options',
|
||||
'clusters.imageCredentials.title': 'Image Credentials',
|
||||
'clusters.imageCredentials.add': 'Add Credential',
|
||||
'clusters.imageCredentials.registry': 'Registry',
|
||||
@@ -180,7 +180,7 @@ export default {
|
||||
'clusters.namespace.title': 'Namespace',
|
||||
'clusters.namespace.tip':
|
||||
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||
'clusters.gpuInstances.title': 'GPU Instances',
|
||||
'clusters.gpuInstances.title': 'GPU Instance Service',
|
||||
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||
'clusters.gpuInstances.staticAddress': 'Static Access Address',
|
||||
'clusters.gpuInstances.staticAddress.tip':
|
||||
|
||||
@@ -166,7 +166,7 @@ export default {
|
||||
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
|
||||
'clusters.systemDefaultContainerRegistry.tip':
|
||||
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
|
||||
'clusters.k8sOptions.title': 'K8s Deployment Options',
|
||||
'clusters.k8sOptions.title': 'Kubernetes Deployment Options',
|
||||
'clusters.imageCredentials.title': 'Image Credentials',
|
||||
'clusters.imageCredentials.add': 'Add Credential',
|
||||
'clusters.imageCredentials.registry': 'Registry',
|
||||
@@ -181,7 +181,7 @@ export default {
|
||||
'clusters.namespace.title': 'Namespace',
|
||||
'clusters.namespace.tip':
|
||||
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||
'clusters.gpuInstances.title': 'GPU Instances',
|
||||
'clusters.gpuInstances.title': 'GPU Instance Service',
|
||||
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||
'clusters.gpuInstances.staticAddress': 'Static Access Address',
|
||||
'clusters.gpuInstances.staticAddress.tip':
|
||||
|
||||
@@ -166,7 +166,7 @@ export default {
|
||||
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
|
||||
'clusters.systemDefaultContainerRegistry.tip':
|
||||
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.',
|
||||
'clusters.k8sOptions.title': 'K8s Deployment Options',
|
||||
'clusters.k8sOptions.title': 'Kubernetes Deployment Options',
|
||||
'clusters.imageCredentials.title': 'Image Credentials',
|
||||
'clusters.imageCredentials.add': 'Add Credential',
|
||||
'clusters.imageCredentials.registry': 'Registry',
|
||||
@@ -181,7 +181,7 @@ export default {
|
||||
'clusters.namespace.title': 'Namespace',
|
||||
'clusters.namespace.tip':
|
||||
'Kubernetes namespace the cluster’s manifests render into. Leave empty to use gpustack-system.',
|
||||
'clusters.gpuInstances.title': 'GPU Instances',
|
||||
'clusters.gpuInstances.title': 'GPU Instance Service',
|
||||
'clusters.gpuInstances.tip': 'Enable GPU instance support for this cluster.',
|
||||
'clusters.gpuInstances.staticAddress': 'Static Access Address',
|
||||
'clusters.gpuInstances.staticAddress.tip':
|
||||
|
||||
@@ -158,7 +158,7 @@ export default {
|
||||
'clusters.systemDefaultContainerRegistry.title': '默认容器镜像仓库',
|
||||
'clusters.systemDefaultContainerRegistry.tip':
|
||||
'用于解析该集群 GPUStack 镜像的默认镜像仓库。未设置时回退到服务端默认值。',
|
||||
'clusters.k8sOptions.title': 'K8s 部署选项',
|
||||
'clusters.k8sOptions.title': 'Kubernetes 部署选项',
|
||||
'clusters.imageCredentials.title': '镜像仓库凭证',
|
||||
'clusters.imageCredentials.add': '添加凭证',
|
||||
'clusters.imageCredentials.registry': '镜像仓库地址',
|
||||
@@ -173,7 +173,7 @@ export default {
|
||||
'clusters.namespace.title': '命名空间',
|
||||
'clusters.namespace.tip':
|
||||
'集群清单渲染所使用的 Kubernetes 命名空间。留空则使用 gpustack-system。',
|
||||
'clusters.gpuInstances.title': 'GPU 实例',
|
||||
'clusters.gpuInstances.title': 'GPU 实例服务',
|
||||
'clusters.gpuInstances.tip': '为该集群启用 GPU 实例支持。',
|
||||
'clusters.gpuInstances.staticAddress': '静态访问地址',
|
||||
'clusters.gpuInstances.staticAddress.tip':
|
||||
|
||||
@@ -34,8 +34,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
const [form] = Form.useForm();
|
||||
const intl = useIntl();
|
||||
const [activeKey, setActiveKey] = React.useState<string[]>([]);
|
||||
// K8s deployment options is its own top-level section (sibling of Advanced),
|
||||
// open by default so the fields are visible without an extra click.
|
||||
const [k8sActiveKey, setK8sActiveKey] = React.useState<string[]>([
|
||||
'k8sOptions'
|
||||
]);
|
||||
@@ -59,20 +57,8 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
}
|
||||
}, [activeKey, action]);
|
||||
|
||||
// The backend models the optional k8s_options string knobs as
|
||||
// Optional[str] and treats null/absent as "use the server default" or
|
||||
// "no auth". Coerce empty form values to null before sending so a blank
|
||||
// input is unambiguous rather than an empty string that defeats fallbacks.
|
||||
const normalizeOutgoing = (values: any): any => {
|
||||
const base: any = { ...values };
|
||||
// Top-level cluster field shared by Docker and K8s. Trim then coerce a
|
||||
// blank input to null so clearing it on edit (or a whitespace-only
|
||||
// value) falls back to the server default rather than persisting an
|
||||
// empty string.
|
||||
if (base.system_default_container_registry !== undefined) {
|
||||
base.system_default_container_registry =
|
||||
base.system_default_container_registry?.trim() || null;
|
||||
}
|
||||
|
||||
const opts = base.k8s_options;
|
||||
if (!opts) return base;
|
||||
@@ -88,32 +74,14 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
}));
|
||||
}
|
||||
|
||||
next.operatorImage = opts.operatorImage || null;
|
||||
next.namespace = opts.namespace || null;
|
||||
|
||||
// Presence of gpuInstanceOptions is the enable flag; keep it only when
|
||||
// the toggle left an object behind, coercing a blank address to null.
|
||||
if (opts.gpuInstanceOptions) {
|
||||
next.gpuInstanceOptions = {
|
||||
gpuInstancesAccessStaticAddress:
|
||||
opts.gpuInstanceOptions.gpuInstancesAccessStaticAddress || null
|
||||
};
|
||||
}
|
||||
|
||||
return { ...base, k8s_options: next };
|
||||
};
|
||||
|
||||
const handleOnFinish = (_values: FormData) => {
|
||||
const handleOnFinish = (values: FormData) => {
|
||||
const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue());
|
||||
// antd's onFinish only delivers values for registered Form.Items.
|
||||
// Spreading those on top of `getFieldsValue(true)` clobbers nested
|
||||
// objects (e.g. `k8s_options` would lose values set via setFieldValue),
|
||||
// so we go straight to the full store.
|
||||
const fullValues = form.getFieldsValue(true);
|
||||
|
||||
onFinish(
|
||||
normalizeOutgoing({
|
||||
...fullValues,
|
||||
...values,
|
||||
worker_config: {
|
||||
...workerConfig
|
||||
}
|
||||
@@ -203,10 +171,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
};
|
||||
},
|
||||
validateFields: async () => {
|
||||
// Run validation first to display any field errors. Then read the
|
||||
// FULL store via `getFieldsValue(true)` so values that were set via
|
||||
// setFieldValue on non-registered paths are still included in what we
|
||||
// hand to the API.
|
||||
await form.validateFields();
|
||||
const values = form.getFieldsValue(true);
|
||||
|
||||
@@ -268,7 +232,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<SealTextArea
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
scaleSize
|
||||
label={intl.formatMessage({ id: 'common.table.description' })}
|
||||
></SealTextArea>
|
||||
</Form.Item>
|
||||
@@ -286,11 +250,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
|
||||
label: intl.formatMessage({ id: 'clusters.k8sOptions.title' }),
|
||||
forceRender: true,
|
||||
children: (
|
||||
// Key by cluster id so the section fully remounts when the
|
||||
// active cluster changes. GpuInstanceOptionsForm seeds its
|
||||
// local state from initialValue only once (initializedRef),
|
||||
// so without a remount a reused form instance could carry a
|
||||
// previous cluster's GPU instance config into the next one.
|
||||
<K8sPodSpec
|
||||
key={currentData?.id ?? 'new'}
|
||||
action={action}
|
||||
|
||||
@@ -4,10 +4,15 @@ import {
|
||||
PlusOutlined,
|
||||
QuestionCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { Input as CInput, LabelSelector, useAppUtils } from '@gpustack/core-ui';
|
||||
import {
|
||||
Input as CInput,
|
||||
LabelSelector,
|
||||
SwitchCard,
|
||||
useAppUtils
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Form, Switch, Tooltip } from 'antd';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Form, Tooltip } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { GpuInstanceOptions } from '../config/types';
|
||||
import K8SVolumeMount from './k8s-volume-mount';
|
||||
@@ -180,6 +185,7 @@ const NamespaceForm: React.FC = () => {
|
||||
<Form.Item
|
||||
name={['k8s_options', 'namespace']}
|
||||
style={{ marginBottom: 0 }}
|
||||
normalize={(value) => value || null}
|
||||
>
|
||||
<CInput.Input
|
||||
label={intl.formatMessage({ id: 'clusters.namespace.title' })}
|
||||
@@ -201,6 +207,7 @@ const OperatorImageForm: React.FC = () => {
|
||||
<Form.Item
|
||||
name={['k8s_options', 'operatorImage']}
|
||||
style={{ marginBottom: 0 }}
|
||||
normalize={(value) => value || null}
|
||||
>
|
||||
<CInput.Input
|
||||
label={intl.formatMessage({ id: 'clusters.operatorImage.title' })}
|
||||
@@ -211,92 +218,40 @@ const OperatorImageForm: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// GPU-instance support. The backend treats the mere presence of
|
||||
// `gpuInstanceOptions` as the enable flag, so the switch toggles the whole
|
||||
// object in/out of the form rather than setting a boolean field; the static
|
||||
// address (optional even when enabled) is nested underneath.
|
||||
//
|
||||
// We drive the toggle from local state (not Form.useWatch) because the
|
||||
// gpuInstanceOptions path has no registered Form.Item of its own — useWatch
|
||||
// doesn't reliably re-render on setFieldValue for such paths, which left the
|
||||
// switch unresponsive. Local state owns the visible state and we mirror it
|
||||
// into the form via setFieldValue so submit still collects it.
|
||||
const GpuInstanceOptionsForm: React.FC<{
|
||||
initialValue?: GpuInstanceOptions;
|
||||
}> = ({ initialValue }) => {
|
||||
const intl = useIntl();
|
||||
const form = Form.useFormInstance();
|
||||
const [enabled, setEnabled] = useState<boolean>(!!initialValue);
|
||||
const [address, setAddress] = useState<string>(
|
||||
initialValue?.gpuInstancesAccessStaticAddress || ''
|
||||
);
|
||||
const initializedRef = useRef<boolean>(!!initialValue);
|
||||
|
||||
const writeForm = (en: boolean, addr: string) => {
|
||||
form.setFieldValue(
|
||||
['k8s_options', 'gpuInstanceOptions'],
|
||||
en ? { gpuInstancesAccessStaticAddress: addr } : undefined
|
||||
);
|
||||
};
|
||||
|
||||
// Mirror a seeded initial value into the form on mount so submit collects it.
|
||||
useEffect(() => {
|
||||
if (initialValue) {
|
||||
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Adopt currentData arriving after mount (async edit load), once. After the
|
||||
// user has interacted (`initializedRef`), local state owns the section.
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
if (initialValue) {
|
||||
setEnabled(true);
|
||||
setAddress(initialValue.gpuInstancesAccessStaticAddress || '');
|
||||
writeForm(true, initialValue.gpuInstancesAccessStaticAddress || '');
|
||||
initializedRef.current = true;
|
||||
}
|
||||
}, [initialValue]);
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
initializedRef.current = true;
|
||||
setEnabled(checked);
|
||||
if (!checked) {
|
||||
setAddress('');
|
||||
}
|
||||
writeForm(checked, checked ? address : '');
|
||||
};
|
||||
|
||||
const handleAddressChange = (e: any) => {
|
||||
const next = typeof e === 'string' ? e : (e?.target?.value ?? '');
|
||||
setAddress(next);
|
||||
writeForm(true, next);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionWrap>
|
||||
<Title>
|
||||
<div className="flex-center gap-8">
|
||||
<span className="flex-center gap-4">
|
||||
<span>
|
||||
{intl.formatMessage({ id: 'clusters.gpuInstances.title' })}
|
||||
</span>
|
||||
<Tooltip
|
||||
title={intl.formatMessage({ id: 'clusters.gpuInstances.tip' })}
|
||||
>
|
||||
<QuestionCircleOutlined
|
||||
style={{ color: 'var(--ant-color-text-secondary)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Switch checked={enabled} onChange={handleToggle} />
|
||||
</div>
|
||||
</Title>
|
||||
{enabled && (
|
||||
<SwitchCard
|
||||
styles={{
|
||||
wrapper: {
|
||||
borderRadius: 'var(--ant-border-radius-lg)',
|
||||
paddingInline: 14,
|
||||
marginBottom: 24
|
||||
}
|
||||
}}
|
||||
value={enabled}
|
||||
onChange={handleToggle}
|
||||
label={intl.formatMessage({ id: 'clusters.gpuInstances.title' })}
|
||||
description={intl.formatMessage({ id: 'clusters.gpuInstances.tip' })}
|
||||
>
|
||||
<Form.Item
|
||||
name={[
|
||||
'k8s_options',
|
||||
'gpuInstanceOptions',
|
||||
'gpuInstancesAccessStaticAddress'
|
||||
]}
|
||||
normalize={(value) => value || null}
|
||||
noStyle
|
||||
>
|
||||
<CInput.Input
|
||||
isInFormItems={false}
|
||||
value={address}
|
||||
onChange={handleAddressChange}
|
||||
label={intl.formatMessage({
|
||||
id: 'clusters.gpuInstances.staticAddress'
|
||||
})}
|
||||
@@ -304,8 +259,8 @@ const GpuInstanceOptionsForm: React.FC<{
|
||||
id: 'clusters.gpuInstances.staticAddress.tip'
|
||||
})}
|
||||
></CInput.Input>
|
||||
)}
|
||||
</SectionWrap>
|
||||
</Form.Item>
|
||||
</SwitchCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -315,12 +270,12 @@ const K8sPodSpec: React.FC<{
|
||||
}> = ({ action, initialGpuInstanceOptions }) => {
|
||||
return (
|
||||
<>
|
||||
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
|
||||
<NamespaceForm />
|
||||
<K8SVolumeMount action={action}></K8SVolumeMount>
|
||||
<ImageCredentialsForm />
|
||||
<NodeSelectorForm />
|
||||
<OperatorImageForm />
|
||||
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -95,6 +95,7 @@ const ClusterAdvanceConfig: React.FC<{
|
||||
<Form.Item<FormData>
|
||||
name="system_default_container_registry"
|
||||
style={{ marginBottom: 16 }}
|
||||
normalize={(value) => value?.trim?.() || null}
|
||||
>
|
||||
<CInput.Input
|
||||
label={intl.formatMessage({
|
||||
|
||||
Reference in New Issue
Block a user