fix: ux issues

This commit is contained in:
jialin
2026-05-26 19:16:50 +08:00
committed by jialin
parent acb90531b2
commit c6e34db329
27 changed files with 354 additions and 244 deletions
+1
View File
@@ -186,6 +186,7 @@ const baseRoutes = [
name: 'gpuService',
path: '/gpu-service',
key: 'gpuService',
access: 'canSeeAdmin',
routes: [
{
path: '/gpu-service',
+10
View File
@@ -46,7 +46,11 @@ export default {
'gpuservice.storageType.kind': 'Type',
'gpuservice.storageType.mountOptions': 'Mount Options',
'gpuservice.storageType.nfs.server': 'NFS Server',
'gpuservice.storageType.nfs.server.tips':
'Ensure the NFS server address is reachable from all Kubernetes clusters.',
'gpuservice.storageType.nfs.share': 'Share Path',
'gpuservice.storageType.nfs.share.tips':
'A directory based on the organization and storage names will be automatically created within this share path. If a subdirectory is specified, the generated directory will be created under that subdirectory.',
'gpuservice.storageType.nfs.subDirectory': 'Sub Directory',
'gpuservice.storageType.nfs.subDirectory.tips':
'If empty, a subdirectory named after the persistent volume will be created. If set, a directory with the persistent volume name will be created beneath this subdirectory.',
@@ -54,11 +58,17 @@ export default {
'gpuservice.storageType.nfs.mountPermissions.tips':
'Inherit the file permissions from the NFS server.',
'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.tips':
'Ensure the S3 endpoint is reachable from all Kubernetes clusters.',
'gpuservice.storageType.s3.endpoint.rule': 'Must start with http or https',
'gpuservice.storageType.s3.region': 'Region',
'gpuservice.storageType.s3.bucket': 'Bucket',
'gpuservice.storageType.s3.bucket.tips':
'If empty, a new bucket named after the persistent volume will be created. If set, a subdirectory with the persistent volume name will be created inside this bucket.',
'gpuservice.storageType.s3.bucket.tips1':
'A prefix based on the organization and storage names will be automatically created within this bucket.',
'gpuservice.storageType.s3.bucket.tips2':
'For example, if the organization is named <span class="desc-block">awesome-group</span> and the storage is named <span class="desc-block">storage-1</span>, the resulting prefix will be <span class="desc-block">awesome-group/storage-1</span>.',
'gpuservice.storageType.s3.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': 'Skip TLS/SSL certificate verification',
+10
View File
@@ -44,7 +44,11 @@ export default {
'gpuservice.storageType.kind': '種別',
'gpuservice.storageType.mountOptions': 'マウントオプション',
'gpuservice.storageType.nfs.server': 'NFS サーバー',
'gpuservice.storageType.nfs.server.tips':
'すべての Kubernetes クラスターから NFS サーバーアドレスにアクセスできることを確認してください。',
'gpuservice.storageType.nfs.share': '共有パス',
'gpuservice.storageType.nfs.share.tips':
'この共有パス配下に、組織名とストレージ名に基づくディレクトリが自動的に作成されます。サブディレクトリが指定されている場合、生成されたディレクトリはそのサブディレクトリ配下に作成されます。',
'gpuservice.storageType.nfs.subDirectory': 'サブディレクトリ',
'gpuservice.storageType.nfs.subDirectory.tips':
'空の場合、永続ボリューム名のサブディレクトリが作成されます。設定されている場合、このサブディレクトリ配下に永続ボリューム名のディレクトリが作成されます。',
@@ -52,12 +56,18 @@ export default {
'gpuservice.storageType.nfs.mountPermissions.tips':
'NFS サーバー上のファイル権限を継承します。',
'gpuservice.storageType.s3.endpoint': 'エンドポイント',
'gpuservice.storageType.s3.endpoint.tips':
'すべての Kubernetes クラスターから S3 エンドポイントにアクセスできることを確認してください。',
'gpuservice.storageType.s3.endpoint.rule':
'http または https で始まる必要があります',
'gpuservice.storageType.s3.region': 'リージョン',
'gpuservice.storageType.s3.bucket': 'バケット',
'gpuservice.storageType.s3.bucket.tips':
'空の場合、永続ボリューム名で新しいバケットが作成されます。設定されている場合、このバケット配下に永続ボリューム名のサブディレクトリが作成されます。',
'gpuservice.storageType.s3.bucket.tips1':
'このバケット内に、組織名とストレージ名に基づくプレフィックスディレクトリが自動的に作成されます。',
'gpuservice.storageType.s3.bucket.tips2':
'例えば、組織名が <span class="desc-block">awesome-group</span>、ストレージ名が <span class="desc-block">storage-1</span> の場合、生成されるプレフィックスは <span class="desc-block">awesome-group/storage-1</span> になります。',
'gpuservice.storageType.s3.accessKey': 'アクセスキー',
'gpuservice.storageType.s3.secretKey': 'シークレットキー',
'gpuservice.storageType.s3.insecure': 'TLS/SSL 証明書の検証をスキップ',
+12 -1
View File
@@ -47,7 +47,11 @@ export default {
'gpuservice.storageType.kind': 'Тип',
'gpuservice.storageType.mountOptions': 'Параметры монтирования',
'gpuservice.storageType.nfs.server': 'Сервер NFS',
'gpuservice.storageType.nfs.server.tips':
'Убедитесь, что адрес NFS-сервера доступен из всех кластеров Kubernetes.',
'gpuservice.storageType.nfs.share': 'Путь общего ресурса',
'gpuservice.storageType.nfs.share.tips':
'В этом общем пути будет автоматически создан каталог на основе названия организации и названия хранилища. Если указан подкаталог, итоговый каталог будет создан внутри него.',
'gpuservice.storageType.nfs.subDirectory': 'Подкаталог',
'gpuservice.storageType.nfs.subDirectory.tips':
'Если поле пустое, будет создан подкаталог с именем постоянного тома. Если задано, под этим подкаталогом будет создан каталог с именем постоянного тома.',
@@ -55,15 +59,22 @@ export default {
'gpuservice.storageType.nfs.mountPermissions.tips':
'Наследует права файлов с NFS-сервера.',
'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.tips':
'Убедитесь, что S3 endpoint доступен из всех кластеров Kubernetes.',
'gpuservice.storageType.s3.endpoint.rule':
'Должен начинаться с http или https',
'gpuservice.storageType.s3.region': 'Регион',
'gpuservice.storageType.s3.bucket': 'Бакет',
'gpuservice.storageType.s3.bucket.tips':
'Если поле пустое, будет создан новый бакет с именем постоянного тома. Если задано, в этом бакете будет создан подкаталог с именем постоянного тома.',
'gpuservice.storageType.s3.bucket.tips1':
'В этом бакете будет автоматически создан префикс на основе названия организации и названия хранилища.',
'gpuservice.storageType.s3.bucket.tips2':
'Например, если организация называется <span class="desc-block">awesome-group</span>, а хранилище — <span class="desc-block">storage-1</span>, итоговый префикс будет: <span class="desc-block">awesome-group/storage-1</span>.',
'gpuservice.storageType.s3.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': 'Пропустить проверку сертификата TLS/SSL',
'gpuservice.storageType.s3.insecure':
'Пропустить проверку сертификата TLS/SSL',
'gpuservice.storageType.s3.insecure.tips':
'Если включено, сертификат сервера S3 не проверяется. Подходит для внутреннего тестирования или самоподписанных сертификатов; в производственной среде включайте с осторожностью.',
'gpuservice.publicKey': 'Открытый ключ SSH',
+11 -2
View File
@@ -46,7 +46,11 @@ export default {
'gpuservice.storageType.kind': 'Tür',
'gpuservice.storageType.mountOptions': 'Bağlama Seçenekleri',
'gpuservice.storageType.nfs.server': 'NFS Sunucusu',
'gpuservice.storageType.nfs.server.tips':
'NFS sunucu adresinin tüm Kubernetes kümelerinden erişilebilir olduğundan emin olun.',
'gpuservice.storageType.nfs.share': 'Paylaşım Yolu',
'gpuservice.storageType.nfs.share.tips':
'Bu paylaşım yolu altında organizasyon ve depolama adlarına dayalı bir dizin otomatik olarak oluşturulur. Bir alt dizin belirtilmişse, oluşturulan dizin o alt dizin altında yer alır.',
'gpuservice.storageType.nfs.subDirectory': 'Alt Dizin',
'gpuservice.storageType.nfs.subDirectory.tips':
'Boş bırakılırsa kalıcı hacim adıyla bir alt dizin oluşturulur. Doldurulursa bu alt dizinin altında kalıcı hacim adıyla bir dizin oluşturulur.',
@@ -54,12 +58,17 @@ export default {
'gpuservice.storageType.nfs.mountPermissions.tips':
'NFS sunucusundaki dosya izinleri devralınır.',
'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.rule':
'http veya https ile başlamalıdır',
'gpuservice.storageType.s3.endpoint.tips':
'S3 endpoint adresinin tüm Kubernetes kümelerinden erişilebilir olduğundan emin olun.',
'gpuservice.storageType.s3.endpoint.rule': 'http veya https ile başlamalıdır',
'gpuservice.storageType.s3.region': 'Bölge',
'gpuservice.storageType.s3.bucket': 'Kova',
'gpuservice.storageType.s3.bucket.tips':
'Boş bırakılırsa kalıcı hacim adıyla yeni bir kova oluşturulur. Doldurulursa bu kova içinde kalıcı hacim adıyla bir alt dizin oluşturulur.',
'gpuservice.storageType.s3.bucket.tips1':
'Bu kova içinde organizasyon ve depolama adlarına dayalı bir önek dizini otomatik olarak oluşturulur.',
'gpuservice.storageType.s3.bucket.tips2':
'Örneğin, organizasyon adı <span class="desc-block">awesome-group</span> ve depolama adı <span class="desc-block">storage-1</span> ise, oluşacak önek: <span class="desc-block">awesome-group/storage-1</span>.',
'gpuservice.storageType.s3.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': 'TLS/SSL sertifika doğrulamasını atla',
+10
View File
@@ -41,7 +41,11 @@ export default {
'gpuservice.storageType.kind': '类型',
'gpuservice.storageType.mountOptions': '挂载参数',
'gpuservice.storageType.nfs.server': 'NFS 服务器',
'gpuservice.storageType.nfs.server.tips':
'确保所有 Kubernetes 集群都能访问该 NFS 服务地址。',
'gpuservice.storageType.nfs.share': '共享路径',
'gpuservice.storageType.nfs.share.tips':
'系统会在该共享路径下自动创建一个基于组织名称和存储名称的目录。如果指定了子目录,则生成的目录会创建在该子目录下。',
'gpuservice.storageType.nfs.subDirectory': '子目录',
'gpuservice.storageType.nfs.subDirectory.tips':
'如果为空,会以持久卷的卷名创建一个子目录;如果不为空,会在同名子目录下,以持久卷的卷名创建一个孙目录。',
@@ -49,11 +53,17 @@ export default {
'gpuservice.storageType.nfs.mountPermissions.tips':
'沿用 NFS 服务器文件的权限。',
'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.tips':
'确保所有 Kubernetes 集群都能访问该 S3 Endpoint。',
'gpuservice.storageType.s3.endpoint.rule': '必须以 http 或 https 开头',
'gpuservice.storageType.s3.region': '区域',
'gpuservice.storageType.s3.bucket': '存储桶',
'gpuservice.storageType.s3.bucket.tips':
'如果为空,会以持久卷的卷名创建一个新桶;如果不为空,会在同名桶下,以持久卷的卷名创建一个子目录。',
'gpuservice.storageType.s3.bucket.tips1':
'系统会在该 Bucket 中自动创建一个基于组织名称和存储名称的前缀目录。',
'gpuservice.storageType.s3.bucket.tips2':
'例如,若组织名称为 <span class="desc-block">awesome-group</span>,存储名称为 <span class="desc-block">storage-1</span>,则生成的前缀为:<span class="desc-block">awesome-group/storage-1</span>。',
'gpuservice.storageType.s3.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': '跳过 TLS/SSL 证书验证',
@@ -31,13 +31,6 @@ type AddModalProps = {
onCancel: () => void;
};
type InstanceTypeSelection = {
instanceType?: string;
manufacturer?: string;
};
const EMPTY_INSTANCE_TYPE_SELECTION: InstanceTypeSelection = {};
const matchKeyword = (fields: Array<unknown>, keyword: string) => {
const trimmed = keyword.trim().toLowerCase();
if (!trimmed) return true;
@@ -85,11 +78,17 @@ const AddModal: React.FC<AddModalProps> = ({
const intl = useIntl();
const form = useRef<any>(null);
const sessionRef = useRef(0);
const [instanceTypeSelection, setInstanceTypeSelection] =
useState<InstanceTypeSelection>(EMPTY_INSTANCE_TYPE_SELECTION);
const [instanceTypeSelection, setInstanceTypeSelection] = useState<{
instanceType?: string;
manufacturer?: string;
}>({
instanceType: undefined,
manufacturer: undefined
});
const [templateId, setTemplateId] = useState<number | undefined>();
const [instanceKeyword, setInstanceKeyword] = useState('');
const [templateKeyword, setTemplateKeyword] = useState('');
const [loading, setLoading] = useState(false);
const {
detailData,
@@ -116,6 +115,20 @@ const AddModal: React.FC<AddModalProps> = ({
: undefined;
};
const saveInstanceDataInDescription = (instanceType: InstanceTypeItem) => {
return JSON.stringify(
{
name: instanceType.name,
spec: {
...instanceType.spec
}
},
null,
2
);
};
// apply the selection of instance type and template
const applySelection = (
instanceType: InstanceTypeItem,
template: TemplateItem | undefined
@@ -126,37 +139,29 @@ const AddModal: React.FC<AddModalProps> = ({
instanceType: instanceType.name,
manufacturer
});
setTemplateId(template?.id);
if (template) {
const currentSpec = form.current?.getFieldsValue()?.spec || {};
const formValues = form.current?.getFieldsValue();
form.current?.setFieldsValue({
manufacturer: template.manufacturer,
description: JSON.stringify(
{
name: instanceType.name,
spec: {
...instanceType.spec
}
},
null,
2
),
description: saveInstanceDataInDescription(instanceType),
spec: {
...currentSpec,
...formValues?.spec,
...template.spec,
resources: {
...(currentSpec.resources || {}),
...(template.spec?.resources || {})
sshPublicKeys: formValues?.spec?.sshPublicKeys,
volume: {
...formValues?.spec?.volume
}
}
});
} else {
form.current?.setFieldsValue({
manufacturer: undefined,
description: JSON.stringify(instanceType.spec, null, 2)
description: saveInstanceDataInDescription(instanceType)
});
}
// update form
form.current?.applyInstanceType?.(instanceType);
};
@@ -175,6 +180,7 @@ const AddModal: React.FC<AddModalProps> = ({
);
};
// initial
const applyAutoSelection = (
instanceTypes: InstanceTypeItem[],
templates: TemplateItem[]
@@ -208,7 +214,10 @@ const AddModal: React.FC<AddModalProps> = ({
useEffect(() => {
if (!open) {
sessionRef.current += 1;
setInstanceTypeSelection(EMPTY_INSTANCE_TYPE_SELECTION);
setInstanceTypeSelection({
instanceType: undefined,
manufacturer: undefined
});
setTemplateId(undefined);
setInstanceKeyword('');
setTemplateKeyword('');
@@ -240,6 +249,7 @@ const AddModal: React.FC<AddModalProps> = ({
) {
return false;
}
return matchKeyword(
[item.name, item.spec?.image, item.spec?.volumeMount],
templateKeyword
@@ -256,9 +266,14 @@ const AddModal: React.FC<AddModalProps> = ({
};
const onFinish = async (values: FormData) => {
onOk({
...values
});
setLoading(true);
try {
await onOk({
...values
});
} finally {
setLoading(false);
}
};
const handleInstanceTypeChange = (item: InstanceTypeItem) => {
@@ -271,10 +286,19 @@ const AddModal: React.FC<AddModalProps> = ({
const handleTemplateChange = (id: number, item: TemplateItem) => {
setTemplateId(id);
const formValues = form.current?.getFieldsValue();
form.current?.setFieldsValue({
spec: {
...form.current?.getFieldsValue()?.spec,
...item.spec
...formValues?.spec,
...item.spec,
sshPublicKeys: formValues?.spec?.sshPublicKeys,
resources: {
...item?.spec?.resources,
accelerator: formValues?.spec?.resources?.accelerator
},
volume: {
...formValues?.spec?.volume
}
}
});
};
@@ -400,6 +424,7 @@ const AddModal: React.FC<AddModalProps> = ({
onOk={handleSubmit}
onCancel={handleCancel}
showOkBtn={!readonly}
loading={loading}
style={{
padding: '16px 24px 8px',
display: 'flex',
@@ -34,7 +34,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
loading
}) => {
const handleSelect = (item: InstanceTypeItemModel) => {
if (!isAvailable(item)) return;
if (!isAvailable(item) || value === item.name) return;
onChange?.(item);
};
@@ -19,6 +19,7 @@ type ViewEventsModalProps = {
name: string;
namespace: string;
clusterID?: number;
hasPersistentVolume?: boolean;
onCancel: () => void;
};
@@ -31,7 +32,8 @@ const eventTypeStatus: Record<string, 'success' | 'warning' | 'error'> = {
const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
const intl = useIntl();
const { open, onCancel, name, namespace, clusterID } = props || {};
const { open, onCancel, name, namespace, clusterID, hasPersistentVolume } =
props || {};
const [activeKey, setActiveKey] = useState('instance');
const {
@@ -54,7 +56,9 @@ const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
const refreshAll = () => {
if (!name || !namespace || !clusterID) return;
fetchInstanceEvents({ name, namespace, clusterID });
fetchVolumeEvents({ name, namespace, clusterID });
if (hasPersistentVolume) {
fetchVolumeEvents({ name, namespace, clusterID });
}
};
useEffect(() => {
@@ -184,16 +188,20 @@ const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
}),
children: renderTable(instanceEvents, instanceLoading)
},
{
key: 'volume',
label: intl.formatMessage({
id: 'gpuservice.instance.event.tab.volume'
}),
children: renderTable(volumeEvents, volumeLoading)
}
...(hasPersistentVolume
? [
{
key: 'volume',
label: intl.formatMessage({
id: 'gpuservice.instance.event.tab.volume'
}),
children: renderTable(volumeEvents, volumeLoading)
}
]
: [])
];
const isLoading = instanceLoading || volumeLoading;
const isLoading = instanceLoading || (hasPersistentVolume && volumeLoading);
return (
<ScrollerModal
+24 -87
View File
@@ -17,6 +17,7 @@ import {
import { useIntl, useNavigate } from '@umijs/max';
import { Button, Empty, Form } from 'antd';
import { useSetAtom } from 'jotai';
import _ from 'lodash';
import {
forwardRef,
useEffect,
@@ -229,6 +230,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
instanceType.status?.acceleratorTiers,
count
);
setSelectedInstanceType(instanceType);
setOnceMaxRequest({
cpu: parseQuantityToNumber(candidate?.cpu?.onceMaxRequest),
@@ -237,11 +239,11 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
});
form.setFieldsValue({
clusterId: candidate?.cluster ? Number(candidate.cluster) : null,
clusterId: candidate?.cluster ? _.toNumber(candidate.cluster) : null,
spec: {
type: candidate?.name || '',
...(options.writeAccelerator
? { resources: { accelerator: count } }
? { resources: { accelerator: _.toString(count) } }
: {})
} as any
});
@@ -278,91 +280,23 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}
if (
currentData &&
(action === PageAction.EDIT ||
action === PageAction.VIEW ||
realAction === PageAction.CREATE)
action === PageAction.EDIT ||
action === PageAction.VIEW ||
realAction === PageAction.CREATE
) {
// The server returns `accelerator` as a string; coerce to a number
// so NumberSelection's strict equality picks up the active item.
const persistedAccelerator = currentData.spec?.resources?.accelerator;
const acceleratorAsNumber =
persistedAccelerator != null && persistedAccelerator !== ''
? Number(persistedAccelerator)
: undefined;
form.setFieldsValue({
name: currentData.name,
displayName: currentData.displayName,
description: currentData.description,
clusterId: currentData.clusterId,
spec: {
...currentData.spec,
imagePullPolicy:
currentData.spec?.imagePullPolicy || DefaultImagePullPolicy,
resources: {
...currentData.spec?.resources,
accelerator: acceleratorAsNumber
},
sshPublicKeys:
currentData.spec?.sshPublicKeys?.map((k) => k.name) ?? []
} as any,
enable_ssh: !!currentData.spec?.sshPublicKeys?.length
...currentData,
enable_ssh: !!currentData?.spec?.sshPublicKeys?.length
});
const candidateName = currentData.spec?.type;
const clusterId = currentData.clusterId;
const aggregate = instanceTypeList.find((item) =>
(item.status?.acceleratorTiers ?? []).some((tier) =>
(tier.candidates ?? []).some(
(c) => c.name === candidateName && Number(c.cluster) === clusterId
)
)
);
if (aggregate) {
const count =
Number(currentData.spec?.resources?.accelerator) ||
(aggregate.spec?.acceleratable ? 1 : 0);
// No accelerator rewrite — the form already has the persisted value.
resolveAndApply(aggregate, count);
}
return;
}
}, [action, currentData, form, open, realAction, instanceTypeList]);
const handleFinish = async (values: InstanceFormValues) => {
const selectedKeys = (values.spec as any)?.sshPublicKeys as
| string[]
| undefined;
const volume = values.spec?.volume ?? {};
const normalizedVolume = volume.persistentTemplate
? {
persistentTemplate: {
...volume.persistentTemplate,
name: volume.persistentTemplate.name || values.name || ''
}
}
: volume.persistent
? { persistent: volume.persistent }
: volume.ephemeral
? { ephemeral: volume.ephemeral }
: {};
// `spec.type` and `clusterId` are kept in sync with the resolved
// candidate via `resolveAndApply`, so submission is a straight pass
// -through. The API expects `accelerator` as a string per the schema.
const acceleratorValue = values.spec?.resources?.accelerator;
const normalizedResources = {
...values.spec?.resources,
accelerator:
acceleratorValue != null && acceleratorValue !== ''
? String(acceleratorValue)
: undefined
};
const submittedPorts = [...(values.spec?.ports ?? [])];
const submittedHasSSHPort = submittedPorts.some(
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
);
if (values.enable_ssh && !submittedHasSSHPort) {
submittedPorts.push({
protocol: 'TCP',
@@ -370,17 +304,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
name: 'SSH'
});
}
console.log('submit values', values, submittedPorts);
await onFinish({
...values,
..._.omit(values, ['enable_ssh']),
spec: {
...values.spec,
ports: submittedPorts,
resources: normalizedResources,
volume: normalizedVolume,
sshPublicKeys: values.enable_ssh
? (selectedKeys ?? []).map((name) => ({ name }))
: []
ports: submittedPorts
}
});
};
@@ -451,7 +380,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
},
volume: {
ephemeral: {
capacity: '20Gi'
capacity: '50Gi'
},
persistent: {
name: ''
@@ -462,8 +391,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}}
>
<Basic action={formAction} disabled={disabled} />
{/* Hidden form field: the candidate.cluster resolved from the
selected aggregate + accelerator count. */}
<Form.Item name="clusterId" hidden>
<CInput.Input />
</Form.Item>
@@ -537,6 +464,16 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
style={{
marginBottom: 12
}}
normalize={(value) =>
Array.isArray(value)
? value?.map((item) => ({ name: item }))
: []
}
getValueProps={(value) => ({
value: Array.isArray(value)
? value.map((item) => item?.name ?? item)
: []
})}
rules={[
{
required: true,
@@ -79,13 +79,6 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
return selectedInstanceType?.spec?.acceleratable;
}, [selectedInstanceType, action]);
const selectedInstanceData = useMemo(() => {
if (action === PageAction.EDIT) {
return JSON.parse(currentData?.description || '{}') || {};
}
return selectedInstanceType;
}, [selectedInstanceType, action, currentData]);
const renderInstanceType = () => {
const description = JSON.parse(currentData?.description || '{}').spec || {};
return (
@@ -130,6 +123,10 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
<Form.Item<FormData>
name={['spec', 'resources', 'accelerator']}
hidden={action === PageAction.EDIT}
normalize={(value) => (value ? _.toString(value) : undefined)}
getValueProps={(value) => ({
value: value ? _.toNumber(value) : undefined
})}
rules={[
{
required: true,
@@ -1,8 +1,10 @@
import { PageAction } from '@/config';
import FormOverlayView from '@/pages/_components/form-overlay-view';
import { FormContext } from '@/pages/gpu-service/storage/config/form-context';
import useQueryStorageClass from '@/pages/gpu-service/storage/services/use-query-storage-class';
import { ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FormData as StorageFormData } from '../../storage/config/types';
import GPUServiceStorageForm from '../../storage/forms';
@@ -18,8 +20,17 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
onSubmit
}) => {
const intl = useIntl();
const { storageClassList, fetchData: fetchStorageClass } =
useQueryStorageClass();
const [loading, setLoading] = useState(false);
const formRef = useRef<any>(null);
useEffect(() => {
if (open) {
fetchStorageClass({ page: -1 });
}
}, [open]);
const handleSubmit = () => {
formRef.current?.submit();
};
@@ -30,7 +41,12 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
};
const handleFinish = async (values: StorageFormData) => {
await onSubmit(values);
setLoading(true);
try {
await onSubmit(values);
} finally {
setLoading(false);
}
};
const getOverlayContainer = useCallback(() => {
@@ -52,6 +68,7 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
loading={loading}
style={{
padding: '16px 24px 24px',
display: 'flex',
@@ -60,12 +77,14 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
/>
}
>
<GPUServiceStorageForm
ref={formRef}
action={PageAction.CREATE}
open={open}
onFinish={handleFinish}
/>
<FormContext.Provider value={{ storageClassList }}>
<GPUServiceStorageForm
ref={formRef}
action={PageAction.CREATE}
open={open}
onFinish={handleFinish}
/>
</FormContext.Provider>
</FormOverlayView>
);
};
@@ -66,12 +66,18 @@ const StorageVolume = ({
const applyMode = (mode: string) => {
if (mode === StorageModeValueMap.Temporary) {
form.setFieldValue(['spec', 'volume'], {
ephemeral: { capacity: `${DEFAULT_TEMP_CAPACITY_GB}Gi` }
});
form.setFieldValue(
['spec', 'volume', 'ephemeral', 'capacity'],
form.getFieldValue(['spec', 'volume', 'ephemeral', 'capacity']) ||
DEFAULT_TEMP_CAPACITY_GB
);
return;
}
form.setFieldValue(['spec', 'volume'], { persistent: { name: '' } });
form.setFieldValue(
['spec', 'volume', 'persistent', 'name'],
form.getFieldValue(['spec', 'volume', 'persistent', 'name']) ||
(storageOptions[0]?.value as string)
);
};
const handleModeChange = (mode: string) => {
@@ -82,10 +88,8 @@ const StorageVolume = ({
const handleCreateStorage = async (values: StorageFormData) => {
try {
await createStorage({ data: values });
await fetchStorage({ page: 1, perPage: 100 });
form.setFieldValue(['spec', 'volume'], {
persistent: { name: values.name }
});
await fetchStorage({ page: -1 });
form.setFieldValue(['spec', 'volume', 'persistent', 'name'], values.name);
setOverlayOpen(false);
} catch (error) {
// ignore
@@ -53,6 +53,12 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
dataList = []
}) => {
const intl = useIntl();
const handleSelect = (item: TemplateItem) => {
if (value === item.id) return;
onChange?.(item.id, item);
};
return (
<TemplateGrid>
{dataList.map((item: TemplateItem) => (
@@ -63,7 +69,7 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
hoverable
height={102}
active={value === item.id}
onClick={() => onChange?.(item.id, item)}
onClick={() => handleSelect(item)}
>
<TemplateContent>
<div className="name">
@@ -9,19 +9,23 @@ const useViewEvents = () => {
name: string;
namespace: string;
clusterID?: number;
hasPersistentVolume: boolean;
}>({
open: false,
name: '',
namespace: '',
clusterID: undefined
clusterID: undefined,
hasPersistentVolume: false
});
const openModal = (row?: ListItem) => {
const volume = row?.spec?.volume;
setOpenModalStatus({
open: true,
name: row?.name || '',
namespace: row?.status?.namespace || '',
clusterID: row?.clusterId ?? undefined
clusterID: row?.clusterId ?? undefined,
hasPersistentVolume: !!volume?.persistent?.name
});
};
@@ -30,7 +34,8 @@ const useViewEvents = () => {
open: false,
name: '',
namespace: '',
clusterID: undefined
clusterID: undefined,
hasPersistentVolume: false
});
};
+2 -1
View File
@@ -80,7 +80,7 @@ const GPUService: React.FC = () => {
if (openInstanceModalStatus.realAction === PageAction.CREATE) {
await deleteGPUServiceInstance(openInstanceModalStatus.currentData!.id);
await new Promise((resolve) => {
setTimeout(resolve, 500);
setTimeout(resolve, 300);
});
await createInstance({ data });
} else if (openInstanceModalStatus.action === PageAction.EDIT) {
@@ -233,6 +233,7 @@ const GPUService: React.FC = () => {
name={openViewEventsModalStatus.name}
namespace={openViewEventsModalStatus.namespace}
clusterID={openViewEventsModalStatus.clusterID}
hasPersistentVolume={openViewEventsModalStatus.hasPersistentVolume}
onCancel={closeViewEventsModal}
/>
<DeleteModal ref={modalRef} />
@@ -2,7 +2,6 @@ import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import {
Input as CInput,
Select as SealSelect,
Textarea,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
@@ -43,13 +42,6 @@ const Basic = ({ action }: { action: string }) => {
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<Form.Item<FormData> name="description">
<Textarea
scaleSize={true}
trim={false}
label={intl.formatMessage({ id: 'common.table.description' })}
/>
</Form.Item>
<Form.Item<FormData>
name="type"
rules={[
@@ -24,6 +24,9 @@ const NFSForm = ({ action }: { action: string }) => {
>
<CInput.Input
required
description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.server.tips'
})}
label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.server'
})}
@@ -40,14 +43,14 @@ const NFSForm = ({ action }: { action: string }) => {
>
<CInput.Input
required
description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.share.tips'
})}
label={intl.formatMessage({ id: 'gpuservice.storageType.nfs.share' })}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'subDirectory']}>
<CInput.Input
description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.subDirectory.tips'
})}
label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.subDirectory'
})}
@@ -12,8 +12,23 @@ import { FormData } from '../config/types';
const S3Form = ({ action }: { action: string }) => {
console.log('action', action);
const intl = useIntl();
const form = Form.useFormInstance<FormData>();
const { getRuleMessage } = useAppUtils();
const handleEndpointBlur = (e: any) => {
const value: string = e.target.value;
if (!value) return;
// Auto-fill region based on endpoint if it's an AWS S3 endpoint
const awsMatch = value.match(
/^https?:\/\/s3[.-]([a-z0-9-]+)\.amazonaws\.com/
);
if (awsMatch) {
const region = awsMatch[1];
form.setFieldValue(['spec', 's3', 'region'], region);
}
};
return (
<>
<Form.Item<FormData>
@@ -39,7 +54,11 @@ const S3Form = ({ action }: { action: string }) => {
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.endpoint'
})}
placeholder="http | https://..."
description={intl.formatMessage({
id: 'gpuservice.storageType.s3.endpoint.tips'
})}
onBlur={handleEndpointBlur}
placeholder="https://s3.<region>.amazonaws.com"
/>
</Form.Item>
<Flex gap={16}>
@@ -53,11 +72,36 @@ const S3Form = ({ action }: { action: string }) => {
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData> name={['spec', 's3', 'bucket']}>
<Form.Item<FormData>
name={['spec', 's3', 'bucket']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'gpuservice.storageType.s3.bucket'
)
}
]}
>
<CInput.Input
description={intl.formatMessage({
id: 'gpuservice.storageType.s3.bucket.tips'
})}
required
description={
<Flex orientation="vertical" gap={4} align="start">
<span>
{intl.formatMessage({
id: 'gpuservice.storageType.s3.bucket.tips1'
})}
</span>
<span
dangerouslySetInnerHTML={{
__html: intl.formatMessage({
id: 'gpuservice.storageType.s3.bucket.tips2'
})
}}
/>
</Flex>
}
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.bucket'
})}
@@ -66,18 +66,18 @@ const useStorageTypeColumns = ({
sorter: false,
render: (_text, record) => getKindLabel(record)
},
{
title: intl.formatMessage({ id: 'common.table.description' }),
dataIndex: 'description',
key: 'description',
sorter: false,
ellipsis: { showTitle: false },
render: (text: string) => (
<AutoTooltip ghost minWidth={20}>
{text || '-'}
</AutoTooltip>
)
},
// {
// title: intl.formatMessage({ id: 'common.table.description' }),
// dataIndex: 'description',
// key: 'description',
// sorter: false,
// ellipsis: { showTitle: false },
// render: (text: string) => (
// <AutoTooltip ghost minWidth={20}>
// {text || '-'}
// </AutoTooltip>
// )
// },
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
@@ -2,6 +2,7 @@ import { PageActionType } from '@/config/types';
import { ModalFooter } from '@gpustack/core-ui';
import { useRef } from 'react';
import FormDrawer from '../../../_components/form-drawer';
import { FormContext } from '../config/form-context';
import { FormData, ListItem } from '../config/types';
import GPUServiceStorageForm from '../forms';
@@ -12,6 +13,7 @@ type AddModalProps = {
onOk: (values: FormData) => void;
data?: ListItem | null;
onCancel: () => void;
storageClassList: Global.BaseOption<string>[];
};
const AddModal: React.FC<AddModalProps> = ({
@@ -20,7 +22,8 @@ const AddModal: React.FC<AddModalProps> = ({
open,
onOk,
data,
onCancel
onCancel,
storageClassList
}) => {
const form = useRef<any>(null);
@@ -58,13 +61,15 @@ const AddModal: React.FC<AddModalProps> = ({
/>
}
>
<GPUServiceStorageForm
ref={form}
action={action}
currentData={data}
onFinish={onFinish}
open={open}
/>
<FormContext.Provider value={{ storageClassList }}>
<GPUServiceStorageForm
ref={form}
action={action}
currentData={data}
onFinish={onFinish}
open={open}
/>
</FormContext.Provider>
</FormDrawer>
);
};
@@ -0,0 +1,11 @@
import { createContext } from 'react';
export interface FormContextValue {
storageClassList: Global.BaseOption<string>[];
}
export const FormContext = createContext<FormContextValue>({
storageClassList: []
});
export default FormContext;
+4 -19
View File
@@ -3,26 +3,19 @@ import {
Input as CInput,
InputNumber,
Select as SealSelect,
Textarea,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd';
import { useEffect } from 'react';
import { useContext } from 'react';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormContext } from '../config/form-context';
import { FormData } from '../config/types';
import useQueryStorageClass from '../services/use-query-storage-class';
const Basic = ({ open, action }: { open: boolean; action: string }) => {
const Basic = ({ action, open }: { action: string; open: boolean }) => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const { storageClassList, fetchData, loading } = useQueryStorageClass();
useEffect(() => {
if (open) {
fetchData({ page: 1, perPage: 100 });
}
}, [open]);
const { storageClassList } = useContext(FormContext);
return (
<>
@@ -52,13 +45,6 @@ const Basic = ({ open, action }: { open: boolean; action: string }) => {
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<Form.Item<FormData> name="description">
<Textarea
trim={false}
scaleSize={true}
label={intl.formatMessage({ id: 'common.table.description' })}
/>
</Form.Item>
<Flex gap={16}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
@@ -74,7 +60,6 @@ const Basic = ({ open, action }: { open: boolean; action: string }) => {
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'gpuservice.storage.type' })}
required
loading={loading}
options={storageClassList}
/>
</Form.Item>
@@ -54,7 +54,7 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
preserve={false}
initialValues={{}}
>
<Basic open={open} action={action} />
<Basic action={action} open={open} />
</Form>
);
}
@@ -1,18 +1,20 @@
import { AutoTooltip, DropdownButtons, StatusTag } from '@gpustack/core-ui';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { rowActionList, status, StoragePhaseLabelMap } from '../config';
import { rowActionList } from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void;
storageClassList: Global.BaseOption<string>[];
sortOrder: string[];
}
const useStorageColumns = ({
handleSelect,
storageClassList,
sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
@@ -37,7 +39,14 @@ const useStorageColumns = ({
dataIndex: ['spec', 'type'],
key: 'type',
sorter: false,
render: (value: string) => value || '-'
render: (value: string) => {
return (
<AutoTooltip ghost>
{storageClassList.find((item) => item.value === value)?.label ||
'-'}
</AutoTooltip>
);
}
},
{
title: intl.formatMessage({ id: 'gpuservice.storage.capacity' }),
@@ -46,23 +55,23 @@ const useStorageColumns = ({
sorter: false,
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-')
},
{
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: ['status', 'phase'],
key: 'status',
sorter: false,
render: (value: string) =>
value ? (
<StatusTag
statusValue={{
status: status[value],
text: StoragePhaseLabelMap[value] || value
}}
></StatusTag>
) : (
'-'
)
},
// {
// title: intl.formatMessage({ id: 'common.table.status' }),
// dataIndex: ['status', 'phase'],
// key: 'status',
// sorter: false,
// render: (value: string) =>
// value ? (
// <StatusTag
// statusValue={{
// status: status[value],
// text: StoragePhaseLabelMap[value] || value
// }}
// ></StatusTag>
// ) : (
// '-'
// )
// },
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
@@ -89,7 +98,7 @@ const useStorageColumns = ({
)
}
];
}, [handleSelect, sortOrder, intl]);
}, [handleSelect, sortOrder, storageClassList, intl]);
};
export default useStorageColumns;
+10 -2
View File
@@ -6,6 +6,7 @@ import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, message, Table } from 'antd';
import _ from 'lodash';
import { useEffect } from 'react';
import PageBox from '../../_components/page-box';
import {
deleteGPUServiceStorage,
@@ -17,6 +18,7 @@ import { FormData, ListItem } from './config/types';
import useCreateStorageModal from './hooks/use-create-storage-modal';
import useStorageColumns from './hooks/use-storage-columns';
import useCreateStorage from './services/use-create-storage';
import useQueryStorageClass from './services/use-query-storage-class';
import useUpdateStorage from './services/use-update-storage';
const GPUServiceStorage: React.FC = () => {
@@ -40,16 +42,20 @@ const GPUServiceStorage: React.FC = () => {
fetchAPI: queryGPUServiceStorage,
deleteAPI: deleteGPUServiceStorage,
watch: false,
polling: true,
API: GPU_SERVICE_STORAGE_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.storage' })
});
const { storageClassList, fetchData: fetchStorageClass } =
useQueryStorageClass();
const { fetchData: createStorage } = useCreateStorage();
const { fetchData: updateStorage } = useUpdateStorage();
const { openStorageModalStatus, openStorageModal, closeStorageModal } =
useCreateStorageModal();
useEffect(() => {
fetchStorageClass({ page: -1 });
}, []);
const handleAddStorage = () => {
openStorageModal(
PageAction.CREATE,
@@ -123,6 +129,7 @@ const GPUServiceStorage: React.FC = () => {
const columns = useStorageColumns({
handleSelect,
storageClassList,
sortOrder
});
@@ -170,6 +177,7 @@ const GPUServiceStorage: React.FC = () => {
action={openStorageModalStatus.action}
title={openStorageModalStatus.title}
data={openStorageModalStatus.currentData}
storageClassList={storageClassList}
onCancel={closeStorageModal}
onOk={handleModalOk}
/>
@@ -29,9 +29,9 @@ const protocolOptions = [
];
const wellKnownTcpPortNames: Record<number, string> = {
22: 'ssh',
80: 'http',
443: 'https'
22: 'SSH',
80: 'WEB',
443: 'HTTPS'
};
const getAutoFilledName = (