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
@@ -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} />