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.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 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.staticAddress': 'Static Access Address',
'clusters.gpuInstances.staticAddress.tip':
+2 -2
View File
@@ -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 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.staticAddress': 'Static Access Address',
'clusters.gpuInstances.staticAddress.tip':
+2 -2
View File
@@ -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 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.staticAddress': 'Static Access Address',
'clusters.gpuInstances.staticAddress.tip':
+2 -2
View File
@@ -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 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.staticAddress': 'Static Access Address',
'clusters.gpuInstances.staticAddress.tip':
+2 -2
View File
@@ -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({