style: gpu instance service

This commit is contained in:
jialin
2026-06-01 21:06:56 +08:00
committed by jialin
parent 65ea65e9ae
commit e314f25a77
8 changed files with 49 additions and 134 deletions
+2 -2
View File
@@ -165,7 +165,7 @@ export default {
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry', 'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip': 'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.', '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.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -180,7 +180,7 @@ export default {
'clusters.namespace.title': 'Namespace', 'clusters.namespace.title': 'Namespace',
'clusters.namespace.tip': 'clusters.namespace.tip':
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.', 'Kubernetes namespace the clusters 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.tip': 'Enable GPU instance support for this cluster.',
'clusters.gpuInstances.staticAddress': 'Static Access Address', 'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.gpuInstances.staticAddress.tip': 'clusters.gpuInstances.staticAddress.tip':
+2 -2
View File
@@ -165,7 +165,7 @@ export default {
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry', 'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip': 'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.', '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.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -180,7 +180,7 @@ export default {
'clusters.namespace.title': 'Namespace', 'clusters.namespace.title': 'Namespace',
'clusters.namespace.tip': 'clusters.namespace.tip':
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.', 'Kubernetes namespace the clusters 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.tip': 'Enable GPU instance support for this cluster.',
'clusters.gpuInstances.staticAddress': 'Static Access Address', 'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.gpuInstances.staticAddress.tip': 'clusters.gpuInstances.staticAddress.tip':
+2 -2
View File
@@ -166,7 +166,7 @@ export default {
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry', 'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip': 'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.', '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.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -181,7 +181,7 @@ export default {
'clusters.namespace.title': 'Namespace', 'clusters.namespace.title': 'Namespace',
'clusters.namespace.tip': 'clusters.namespace.tip':
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.', 'Kubernetes namespace the clusters 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.tip': 'Enable GPU instance support for this cluster.',
'clusters.gpuInstances.staticAddress': 'Static Access Address', 'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.gpuInstances.staticAddress.tip': 'clusters.gpuInstances.staticAddress.tip':
+2 -2
View File
@@ -166,7 +166,7 @@ export default {
'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry', 'clusters.systemDefaultContainerRegistry.title': 'Default Container Registry',
'clusters.systemDefaultContainerRegistry.tip': 'clusters.systemDefaultContainerRegistry.tip':
'Default registry used to resolve GPUStack images for this cluster. Falls back to the server default when unset.', '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.title': 'Image Credentials',
'clusters.imageCredentials.add': 'Add Credential', 'clusters.imageCredentials.add': 'Add Credential',
'clusters.imageCredentials.registry': 'Registry', 'clusters.imageCredentials.registry': 'Registry',
@@ -181,7 +181,7 @@ export default {
'clusters.namespace.title': 'Namespace', 'clusters.namespace.title': 'Namespace',
'clusters.namespace.tip': 'clusters.namespace.tip':
'Kubernetes namespace the clusters manifests render into. Leave empty to use gpustack-system.', 'Kubernetes namespace the clusters 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.tip': 'Enable GPU instance support for this cluster.',
'clusters.gpuInstances.staticAddress': 'Static Access Address', 'clusters.gpuInstances.staticAddress': 'Static Access Address',
'clusters.gpuInstances.staticAddress.tip': 'clusters.gpuInstances.staticAddress.tip':
+2 -2
View File
@@ -158,7 +158,7 @@ export default {
'clusters.systemDefaultContainerRegistry.title': '默认容器镜像仓库', 'clusters.systemDefaultContainerRegistry.title': '默认容器镜像仓库',
'clusters.systemDefaultContainerRegistry.tip': 'clusters.systemDefaultContainerRegistry.tip':
'用于解析该集群 GPUStack 镜像的默认镜像仓库。未设置时回退到服务端默认值。', '用于解析该集群 GPUStack 镜像的默认镜像仓库。未设置时回退到服务端默认值。',
'clusters.k8sOptions.title': 'K8s 部署选项', 'clusters.k8sOptions.title': 'Kubernetes 部署选项',
'clusters.imageCredentials.title': '镜像仓库凭证', 'clusters.imageCredentials.title': '镜像仓库凭证',
'clusters.imageCredentials.add': '添加凭证', 'clusters.imageCredentials.add': '添加凭证',
'clusters.imageCredentials.registry': '镜像仓库地址', 'clusters.imageCredentials.registry': '镜像仓库地址',
@@ -173,7 +173,7 @@ export default {
'clusters.namespace.title': '命名空间', 'clusters.namespace.title': '命名空间',
'clusters.namespace.tip': 'clusters.namespace.tip':
'集群清单渲染所使用的 Kubernetes 命名空间。留空则使用 gpustack-system。', '集群清单渲染所使用的 Kubernetes 命名空间。留空则使用 gpustack-system。',
'clusters.gpuInstances.title': 'GPU 实例', 'clusters.gpuInstances.title': 'GPU 实例服务',
'clusters.gpuInstances.tip': '为该集群启用 GPU 实例支持。', 'clusters.gpuInstances.tip': '为该集群启用 GPU 实例支持。',
'clusters.gpuInstances.staticAddress': '静态访问地址', 'clusters.gpuInstances.staticAddress': '静态访问地址',
'clusters.gpuInstances.staticAddress.tip': 'clusters.gpuInstances.staticAddress.tip':
@@ -34,8 +34,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
const [form] = Form.useForm(); const [form] = Form.useForm();
const intl = useIntl(); const intl = useIntl();
const [activeKey, setActiveKey] = React.useState<string[]>([]); 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[]>([ const [k8sActiveKey, setK8sActiveKey] = React.useState<string[]>([
'k8sOptions' 'k8sOptions'
]); ]);
@@ -59,20 +57,8 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
} }
}, [activeKey, action]); }, [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 normalizeOutgoing = (values: any): any => {
const base: any = { ...values }; 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; const opts = base.k8s_options;
if (!opts) return base; 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 }; return { ...base, k8s_options: next };
}; };
const handleOnFinish = (_values: FormData) => { const handleOnFinish = (values: FormData) => {
const workerConfig = yaml2Json(advanceConfigRef.current?.getYamlValue()); 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( onFinish(
normalizeOutgoing({ normalizeOutgoing({
...fullValues, ...values,
worker_config: { worker_config: {
...workerConfig ...workerConfig
} }
@@ -203,10 +171,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
}; };
}, },
validateFields: async () => { 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(); await form.validateFields();
const values = form.getFieldsValue(true); const values = form.getFieldsValue(true);
@@ -268,7 +232,7 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
style={{ marginBottom: 8 }} style={{ marginBottom: 8 }}
> >
<SealTextArea <SealTextArea
autoSize={{ minRows: 2, maxRows: 4 }} scaleSize
label={intl.formatMessage({ id: 'common.table.description' })} label={intl.formatMessage({ id: 'common.table.description' })}
></SealTextArea> ></SealTextArea>
</Form.Item> </Form.Item>
@@ -286,11 +250,6 @@ const ClusterForm: React.FC<AddModalProps> = forwardRef(
label: intl.formatMessage({ id: 'clusters.k8sOptions.title' }), label: intl.formatMessage({ id: 'clusters.k8sOptions.title' }),
forceRender: true, forceRender: true,
children: ( 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 <K8sPodSpec
key={currentData?.id ?? 'new'} key={currentData?.id ?? 'new'}
action={action} action={action}
@@ -4,10 +4,15 @@ import {
PlusOutlined, PlusOutlined,
QuestionCircleOutlined QuestionCircleOutlined
} from '@ant-design/icons'; } 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 { useIntl } from '@umijs/max';
import { Button, Form, Switch, Tooltip } from 'antd'; import { Button, Form, Tooltip } from 'antd';
import React, { useEffect, useRef, useState } from 'react'; import React, { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { GpuInstanceOptions } from '../config/types'; import { GpuInstanceOptions } from '../config/types';
import K8SVolumeMount from './k8s-volume-mount'; import K8SVolumeMount from './k8s-volume-mount';
@@ -180,6 +185,7 @@ const NamespaceForm: React.FC = () => {
<Form.Item <Form.Item
name={['k8s_options', 'namespace']} name={['k8s_options', 'namespace']}
style={{ marginBottom: 0 }} style={{ marginBottom: 0 }}
normalize={(value) => value || null}
> >
<CInput.Input <CInput.Input
label={intl.formatMessage({ id: 'clusters.namespace.title' })} label={intl.formatMessage({ id: 'clusters.namespace.title' })}
@@ -201,6 +207,7 @@ const OperatorImageForm: React.FC = () => {
<Form.Item <Form.Item
name={['k8s_options', 'operatorImage']} name={['k8s_options', 'operatorImage']}
style={{ marginBottom: 0 }} style={{ marginBottom: 0 }}
normalize={(value) => value || null}
> >
<CInput.Input <CInput.Input
label={intl.formatMessage({ id: 'clusters.operatorImage.title' })} 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<{ const GpuInstanceOptionsForm: React.FC<{
initialValue?: GpuInstanceOptions; initialValue?: GpuInstanceOptions;
}> = ({ initialValue }) => { }> = ({ initialValue }) => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance();
const [enabled, setEnabled] = useState<boolean>(!!initialValue); 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) => { const handleToggle = (checked: boolean) => {
initializedRef.current = true;
setEnabled(checked); 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 ( return (
<SectionWrap> <SwitchCard
<Title> styles={{
<div className="flex-center gap-8"> wrapper: {
<span className="flex-center gap-4"> borderRadius: 'var(--ant-border-radius-lg)',
<span> paddingInline: 14,
{intl.formatMessage({ id: 'clusters.gpuInstances.title' })} marginBottom: 24
</span> }
<Tooltip }}
title={intl.formatMessage({ id: 'clusters.gpuInstances.tip' })} value={enabled}
> onChange={handleToggle}
<QuestionCircleOutlined label={intl.formatMessage({ id: 'clusters.gpuInstances.title' })}
style={{ color: 'var(--ant-color-text-secondary)' }} description={intl.formatMessage({ id: 'clusters.gpuInstances.tip' })}
/> >
</Tooltip> <Form.Item
</span> name={[
<Switch checked={enabled} onChange={handleToggle} /> 'k8s_options',
</div> 'gpuInstanceOptions',
</Title> 'gpuInstancesAccessStaticAddress'
{enabled && ( ]}
normalize={(value) => value || null}
noStyle
>
<CInput.Input <CInput.Input
isInFormItems={false}
value={address}
onChange={handleAddressChange}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'clusters.gpuInstances.staticAddress' id: 'clusters.gpuInstances.staticAddress'
})} })}
@@ -304,8 +259,8 @@ const GpuInstanceOptionsForm: React.FC<{
id: 'clusters.gpuInstances.staticAddress.tip' id: 'clusters.gpuInstances.staticAddress.tip'
})} })}
></CInput.Input> ></CInput.Input>
)} </Form.Item>
</SectionWrap> </SwitchCard>
); );
}; };
@@ -315,12 +270,12 @@ const K8sPodSpec: React.FC<{
}> = ({ action, initialGpuInstanceOptions }) => { }> = ({ action, initialGpuInstanceOptions }) => {
return ( return (
<> <>
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
<NamespaceForm /> <NamespaceForm />
<K8SVolumeMount action={action}></K8SVolumeMount> <K8SVolumeMount action={action}></K8SVolumeMount>
<ImageCredentialsForm /> <ImageCredentialsForm />
<NodeSelectorForm /> <NodeSelectorForm />
<OperatorImageForm /> <OperatorImageForm />
<GpuInstanceOptionsForm initialValue={initialGpuInstanceOptions} />
</> </>
); );
}; };
@@ -95,6 +95,7 @@ const ClusterAdvanceConfig: React.FC<{
<Form.Item<FormData> <Form.Item<FormData>
name="system_default_container_registry" name="system_default_container_registry"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
normalize={(value) => value?.trim?.() || null}
> >
<CInput.Input <CInput.Input
label={intl.formatMessage({ label={intl.formatMessage({