fix: ssh key, template test
This commit is contained in:
@@ -5,7 +5,8 @@ import { ColumnWrapper, GSDrawer, ModalFooter } from '@gpustack/core-ui';
|
||||
import { Empty, Input, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
||||
import useQueryTemplates from '../../templates/services/use-query-templates';
|
||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
||||
import GPUServiceInstanceForm from '../forms';
|
||||
import TemplateSelector from '../forms/template-selector';
|
||||
@@ -64,14 +65,18 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||
|
||||
const { detailData, fetchData } = useQueryInstanceTypes();
|
||||
const { detailData: templatesData, fetchData: fetchTemplates } =
|
||||
useQueryTemplates();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchData({});
|
||||
fetchTemplates({ page: 1, perPage: 100 });
|
||||
}
|
||||
}, [open, fetchData]);
|
||||
}, [open, fetchData, fetchTemplates]);
|
||||
|
||||
const instanceTypeList = detailData?.items || [];
|
||||
const templateList = templatesData?.items || [];
|
||||
|
||||
const filteredInstanceTypes = useMemo(() => {
|
||||
const keyword = instanceKeyword.trim().toLowerCase();
|
||||
@@ -93,14 +98,14 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const keyword = templateKeyword.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return mockTemplateData;
|
||||
return templateList;
|
||||
}
|
||||
return mockTemplateData.filter((item) =>
|
||||
[item.name, item.image, item.volumeMount].some((text) =>
|
||||
return templateList.filter((item) =>
|
||||
[item.name, item.spec?.image, item.spec?.volumeMount].some((text) =>
|
||||
(text || '').toLowerCase().includes(keyword)
|
||||
)
|
||||
);
|
||||
}, [templateKeyword]);
|
||||
}, [templateKeyword, templateList]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
form.current?.submit();
|
||||
@@ -118,7 +123,30 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const handleInstanceTypeChange = (item: InstanceTypeItem) => {
|
||||
setInstanceTypeName(item.metadata?.name || item.name);
|
||||
const name = item.metadata?.name || item.name;
|
||||
setInstanceTypeName(name);
|
||||
form.current?.setFieldsValue({
|
||||
type: name,
|
||||
spec: { resources: { accelerator: '1' } }
|
||||
});
|
||||
};
|
||||
|
||||
const handleTemplateChange = (id: number, item: TemplateItem) => {
|
||||
setTemplateId(id);
|
||||
form.current?.setFieldsValue({
|
||||
spec: {
|
||||
image: item.spec?.image,
|
||||
imagePullPolicy: item.spec?.imagePullPolicy,
|
||||
command: item.spec?.command || [],
|
||||
ports: item.spec?.ports || [],
|
||||
env: item.spec?.env || [],
|
||||
volumeMount: item.spec?.volumeMount,
|
||||
resources: {
|
||||
cpu: item.spec?.resources?.cpu,
|
||||
ram: item.spec?.resources?.ram
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -194,7 +222,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
<TemplateSelector
|
||||
value={templateId}
|
||||
dataList={filteredTemplates}
|
||||
onChange={setTemplateId}
|
||||
onChange={handleTemplateChange}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { InstanceStatusValueMap } from '.';
|
||||
import { ListItem } from './types';
|
||||
|
||||
export const mockInstanceData: (ListItem & { cluster_id: number })[] = [
|
||||
{
|
||||
id: 1,
|
||||
cluster_id: 1,
|
||||
metadata: {
|
||||
name: 'cuda-dev-01',
|
||||
namespace: 'default'
|
||||
},
|
||||
spec: {
|
||||
type: 'nvidia-l4-small',
|
||||
image: 'nvidia/cuda:12.4.1-devel-ubuntu22.04',
|
||||
displayName: 'CUDA Dev 01',
|
||||
command: ['/bin/bash'],
|
||||
ports: [{ protocol: 'tcp', port: 22 }],
|
||||
env: [],
|
||||
volumeMount: '/workspace',
|
||||
resources: {
|
||||
cpu: '4',
|
||||
ram: '16Gi',
|
||||
accelerator: '1'
|
||||
},
|
||||
description: 'CUDA development workspace',
|
||||
volume: {
|
||||
persistent: { name: 'local-nvme-cache' }
|
||||
},
|
||||
sshPublicKey: { name: 'default' }
|
||||
},
|
||||
status: InstanceStatusValueMap.Ready,
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
updated_at: '2026-04-10T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
cluster_id: 1,
|
||||
metadata: {
|
||||
name: 'training-job-a100',
|
||||
namespace: 'default'
|
||||
},
|
||||
spec: {
|
||||
type: 'nvidia-a100-training',
|
||||
image: 'pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel',
|
||||
displayName: 'Training A100',
|
||||
command: ['python', 'train.py'],
|
||||
ports: [],
|
||||
env: [{ name: 'PYTHONUNBUFFERED', value: '1' }],
|
||||
volumeMount: '/data',
|
||||
resources: {
|
||||
cpu: '32',
|
||||
ram: '128Gi',
|
||||
accelerator: '4'
|
||||
},
|
||||
description: 'PyTorch training environment',
|
||||
volume: {
|
||||
persistent: { name: 'shared-model-store' }
|
||||
},
|
||||
sshPublicKey: { name: '' }
|
||||
},
|
||||
status: InstanceStatusValueMap.Pending,
|
||||
created_at: '2026-04-02T10:00:00Z',
|
||||
updated_at: '2026-04-11T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
cluster_id: 1,
|
||||
metadata: {
|
||||
name: 'inference-vllm',
|
||||
namespace: 'default'
|
||||
},
|
||||
spec: {
|
||||
type: 'nvidia-h100-inference',
|
||||
image: 'vllm/vllm-openai:latest',
|
||||
displayName: 'Inference vLLM',
|
||||
command: ['python', '-m', 'vllm.entrypoints.openai.api_server'],
|
||||
ports: [{ protocol: 'tcp', port: 8080 }],
|
||||
env: [{ name: 'VLLM_WORKER_MULTIPROC_METHOD', value: 'spawn' }],
|
||||
volumeMount: '/models',
|
||||
resources: {
|
||||
cpu: '16',
|
||||
ram: '64Gi',
|
||||
accelerator: '2'
|
||||
},
|
||||
description: 'OpenAI-compatible inference service',
|
||||
volume: {
|
||||
ephemeral: { capacity: '100Gi' }
|
||||
},
|
||||
sshPublicKey: { name: '' }
|
||||
},
|
||||
status: InstanceStatusValueMap.Error,
|
||||
created_at: '2026-04-03T10:00:00Z',
|
||||
updated_at: '2026-04-12T10:00:00Z'
|
||||
}
|
||||
];
|
||||
@@ -35,6 +35,7 @@ export interface FormData {
|
||||
spec: {
|
||||
type: string;
|
||||
image: string;
|
||||
imagePullPolicy: ImagePullPolicy;
|
||||
displayName: string;
|
||||
command: string[];
|
||||
ports: InstancePort[];
|
||||
|
||||
@@ -18,15 +18,10 @@ import {
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react';
|
||||
import { mockStorageData } from '../../storage/config/mock-data';
|
||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
||||
import {
|
||||
EnvItem as TemplateEnvItem,
|
||||
PortItem as TemplatePortItem
|
||||
} from '../../templates/config/types';
|
||||
import { DefaultImagePullPolicy } from '../../templates/config';
|
||||
import TemplateBasicForm from '../../templates/forms/basic';
|
||||
import { DEFAULT_SSH_PUBLIC_KEY_NAME, StorageModeValueMap } from '../config';
|
||||
import { FormData, ListItem } from '../config/types';
|
||||
import { FormData, InstancePort, ListItem } from '../config/types';
|
||||
import Basic from './basic';
|
||||
import InstanceTypeFormItem from './instance-type';
|
||||
import StorageVolume from './storage-volume';
|
||||
@@ -34,16 +29,6 @@ import StorageVolume from './storage-volume';
|
||||
const SSH_PORT = 22;
|
||||
|
||||
type InstanceFormValues = FormData & {
|
||||
// instance-type holders
|
||||
type?: string;
|
||||
gpu_count?: number;
|
||||
// template holders (mirrored into spec.* on submit)
|
||||
image?: string;
|
||||
command?: string[];
|
||||
ports?: TemplatePortItem[];
|
||||
env?: TemplateEnvItem[];
|
||||
volumeMount?: string;
|
||||
resources?: { cpu?: string; ram?: string };
|
||||
// storage holders
|
||||
storage_mode?: string;
|
||||
storage_name?: string;
|
||||
@@ -75,7 +60,7 @@ const requiredFields = {
|
||||
},
|
||||
[TABKeysMap.INSTANCE_TYPE]: {
|
||||
sort: 2,
|
||||
fields: ['type', 'gpu_count']
|
||||
fields: ['type']
|
||||
},
|
||||
[TABKeysMap.TEMPLATE]: {
|
||||
sort: 3,
|
||||
@@ -144,7 +129,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
[]
|
||||
);
|
||||
|
||||
const ports = (Form.useWatch('ports', form) || []) as TemplatePortItem[];
|
||||
const ports = (Form.useWatch(['spec', 'ports'], form) ||
|
||||
[]) as InstancePort[];
|
||||
const hasSshPort = useMemo(
|
||||
() => ports.some((p) => Number(p?.port) === SSH_PORT),
|
||||
[ports]
|
||||
@@ -180,20 +166,10 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
name: currentData.metadata?.name,
|
||||
namespace: currentData.metadata?.namespace
|
||||
},
|
||||
spec: { ...currentData.spec },
|
||||
type: currentData.spec?.type,
|
||||
gpu_count: currentData.spec?.resources?.accelerator
|
||||
? Number(currentData.spec.resources.accelerator) || 1
|
||||
: 1,
|
||||
// template holders mirrored from spec.*
|
||||
image: currentData.spec?.image,
|
||||
command: currentData.spec?.command || [],
|
||||
ports: (currentData.spec?.ports || []) as TemplatePortItem[],
|
||||
env: (currentData.spec?.env || []) as TemplateEnvItem[],
|
||||
volumeMount: currentData.spec?.volumeMount,
|
||||
resources: {
|
||||
cpu: currentData.spec?.resources?.cpu,
|
||||
ram: currentData.spec?.resources?.ram
|
||||
spec: {
|
||||
...currentData.spec,
|
||||
imagePullPolicy:
|
||||
currentData.spec?.imagePullPolicy || DefaultImagePullPolicy
|
||||
},
|
||||
storage_mode: isExisting
|
||||
? StorageModeValueMap.Existing
|
||||
@@ -207,53 +183,42 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultTemplate = mockTemplateData[0];
|
||||
const defaultStorage = mockStorageData[0];
|
||||
|
||||
form.setFieldsValue({
|
||||
metadata: {
|
||||
namespace
|
||||
},
|
||||
spec: {
|
||||
description: '',
|
||||
displayName: ''
|
||||
} as FormData['spec'],
|
||||
gpu_count: 1,
|
||||
// template holders seeded from default template
|
||||
image: defaultTemplate?.image || '',
|
||||
command: defaultTemplate?.command || [],
|
||||
ports: (defaultTemplate?.ports || []) as TemplatePortItem[],
|
||||
env: (defaultTemplate?.env || []) as TemplateEnvItem[],
|
||||
volumeMount: defaultTemplate?.volumeMount || '',
|
||||
resources: {
|
||||
cpu: defaultTemplate?.resources?.cpu,
|
||||
ram: defaultTemplate?.resources?.ram
|
||||
},
|
||||
displayName: '',
|
||||
image: '',
|
||||
imagePullPolicy: DefaultImagePullPolicy,
|
||||
command: [],
|
||||
ports: [],
|
||||
env: [],
|
||||
volumeMount: '',
|
||||
resources: {
|
||||
accelerator: '1'
|
||||
}
|
||||
} as Partial<FormData['spec']> as FormData['spec'],
|
||||
storage_mode: StorageModeValueMap.Existing,
|
||||
storage_name: defaultStorage?.metadata?.name,
|
||||
local_storage_size_gb: 50,
|
||||
enable_ssh: false
|
||||
});
|
||||
}, [action, currentData, form, open, namespace]);
|
||||
|
||||
const buildPayload = (values: InstanceFormValues): FormData => {
|
||||
const acceleratorStr = values.gpu_count
|
||||
? String(values.gpu_count)
|
||||
: undefined;
|
||||
const acceleratorVal = values.spec?.resources?.accelerator;
|
||||
const acceleratorStr =
|
||||
acceleratorVal !== undefined &&
|
||||
acceleratorVal !== null &&
|
||||
(acceleratorVal as unknown) !== ''
|
||||
? String(acceleratorVal)
|
||||
: undefined;
|
||||
|
||||
const spec: FormData['spec'] = {
|
||||
...values.spec,
|
||||
type: values.type || values.spec?.type,
|
||||
image: values.image ?? values.spec?.image,
|
||||
command: values.command ?? values.spec?.command,
|
||||
ports: (values.ports ??
|
||||
values.spec?.ports) as FormData['spec']['ports'],
|
||||
env: (values.env ?? values.spec?.env) as FormData['spec']['env'],
|
||||
volumeMount: values.volumeMount ?? values.spec?.volumeMount,
|
||||
resources: {
|
||||
...values.spec?.resources,
|
||||
cpu: values.resources?.cpu ?? values.spec?.resources?.cpu,
|
||||
ram: values.resources?.ram ?? values.spec?.resources?.ram,
|
||||
accelerator: acceleratorStr
|
||||
}
|
||||
} as FormData['spec'];
|
||||
@@ -272,7 +237,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
}
|
||||
|
||||
// SSH public key — include only when checkbox is checked AND port 22 is in ports
|
||||
const portsList = (values.ports || []) as TemplatePortItem[];
|
||||
const portsList = (values.spec?.ports || []) as InstancePort[];
|
||||
const has22 = portsList.some((p) => Number(p?.port) === SSH_PORT);
|
||||
if (values.enable_ssh && has22) {
|
||||
spec.sshPublicKey = { name: DEFAULT_SSH_PUBLIC_KEY_NAME };
|
||||
@@ -300,6 +265,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
|
||||
},
|
||||
resetFields: () => {
|
||||
form.resetFields();
|
||||
},
|
||||
setFieldsValue: (values: Partial<InstanceFormValues>) => {
|
||||
form.setFieldsValue(values as any);
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import FormOverlayView from '@/pages/_components/form-overlay-view';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Empty, Input } from 'antd';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import InstanceTypeList from '../components/instance-type-list';
|
||||
import { InstanceTypeItem } from '../config/types';
|
||||
|
||||
const DrawerBody = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
interface InstanceTypeOverlayProps {
|
||||
open: boolean;
|
||||
value?: string;
|
||||
dataList: InstanceTypeItem[];
|
||||
onCancel: () => void;
|
||||
onChange?: (item: InstanceTypeItem) => void;
|
||||
}
|
||||
|
||||
const InstanceTypeOverlay: React.FC<InstanceTypeOverlayProps> = ({
|
||||
open,
|
||||
value,
|
||||
dataList,
|
||||
onCancel,
|
||||
onChange
|
||||
}) => {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
const currentKeyword = keyword.trim().toLowerCase();
|
||||
if (!currentKeyword) {
|
||||
return dataList;
|
||||
}
|
||||
|
||||
return dataList.filter((item) => {
|
||||
const name = item.metadata?.name || item.name || '';
|
||||
return [
|
||||
name,
|
||||
item.spec?.memory ?? '',
|
||||
item.status?.cpu?.capacity ?? '',
|
||||
item.status?.ram?.capacity ?? '',
|
||||
item.status?.accelerator?.remaining ?? ''
|
||||
].some((text) => String(text).toLowerCase().includes(currentKeyword));
|
||||
});
|
||||
}, [keyword, dataList]);
|
||||
|
||||
const handleChange = (item: InstanceTypeItem) => {
|
||||
onChange?.(item);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const getOverlayContainer = useCallback(() => {
|
||||
const containers = document.querySelectorAll<HTMLElement>(
|
||||
'.ant-layout-content'
|
||||
);
|
||||
|
||||
return containers[containers.length - 1] ?? null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FormOverlayView
|
||||
title="选择实例类型"
|
||||
open={open}
|
||||
width={600}
|
||||
onCancel={onCancel}
|
||||
footer={false}
|
||||
getContainer={getOverlayContainer}
|
||||
>
|
||||
<DrawerBody>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined className="text-tertiary" />}
|
||||
placeholder="搜索名称、显存、内存或 vCPU"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
{filteredOptions.length > 0 ? (
|
||||
<InstanceTypeList
|
||||
value={value}
|
||||
dataList={filteredOptions}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</DrawerBody>
|
||||
</FormOverlayView>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstanceTypeOverlay;
|
||||
@@ -4,12 +4,11 @@ import {
|
||||
InputNumber as CInputNumber
|
||||
} from '@gpustack/core-ui';
|
||||
import { Flex, Form, Tag } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { InstanceTypePhaseValueMap } from '../config';
|
||||
import { FormData, InstanceTypeItem } from '../config/types';
|
||||
import useQueryInstanceTypes from '../services/use-query-instance-types';
|
||||
import InstanceTypeOverlay from './instance-type-overlay';
|
||||
|
||||
const FieldBlock = styled.div`
|
||||
margin-bottom: 24px;
|
||||
@@ -54,18 +53,12 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
|
||||
onChange,
|
||||
dataList
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = useMemo(() => {
|
||||
return dataList.find(
|
||||
(item) => (item.metadata?.name || item.name) === value
|
||||
);
|
||||
}, [value, dataList]);
|
||||
|
||||
const handleChange = (item: InstanceTypeItem) => {
|
||||
onChange?.(item.metadata?.name || item.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectedCard>
|
||||
@@ -97,21 +90,12 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
|
||||
</SummaryMeta>
|
||||
</div>
|
||||
</SelectedCard>
|
||||
<InstanceTypeOverlay
|
||||
open={open}
|
||||
value={value}
|
||||
dataList={dataList}
|
||||
onCancel={() => setOpen(false)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InstanceTypeFormItem = () => {
|
||||
const form = Form.useFormInstance<
|
||||
FormData & { type?: string; gpu_count?: number }
|
||||
>();
|
||||
const form = Form.useFormInstance<FormData & { type?: string }>();
|
||||
const typeName = Form.useWatch('type', form) as string | undefined;
|
||||
|
||||
const { detailData, fetchData } = useQueryInstanceTypes();
|
||||
@@ -148,13 +132,14 @@ const InstanceTypeFormItem = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentGpuCount = form.getFieldValue('gpu_count') ?? 1;
|
||||
if (currentGpuCount > maxGpuCount) {
|
||||
form.setFieldValue('gpu_count', maxGpuCount);
|
||||
const currentAccelerator =
|
||||
Number(form.getFieldValue(['spec', 'resources', 'accelerator'])) || 1;
|
||||
if (currentAccelerator > maxGpuCount) {
|
||||
form.setFieldValue(['spec', 'resources', 'accelerator'], maxGpuCount);
|
||||
}
|
||||
|
||||
if (currentGpuCount < 1) {
|
||||
form.setFieldValue('gpu_count', 1);
|
||||
if (currentAccelerator < 1) {
|
||||
form.setFieldValue(['spec', 'resources', 'accelerator'], 1);
|
||||
}
|
||||
}, [form, typeName, maxGpuCount, dataList]);
|
||||
|
||||
@@ -174,7 +159,7 @@ const InstanceTypeFormItem = () => {
|
||||
</Form.Item>
|
||||
</FieldBlock>
|
||||
<Form.Item
|
||||
name="gpu_count"
|
||||
name={['spec', 'resources', 'accelerator']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { PageAction } from '@/config';
|
||||
import FormOverlayView from '@/pages/_components/form-overlay-view';
|
||||
import { ModalFooter } from '@gpustack/core-ui';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { FormData as StorageFormData } from '../../storage/config/types';
|
||||
import GPUServiceStorageForm from '../../storage/forms';
|
||||
|
||||
interface StorageOverlayProps {
|
||||
open: boolean;
|
||||
namespace?: string;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: StorageFormData) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const StorageOverlay: React.FC<StorageOverlayProps> = ({
|
||||
open,
|
||||
namespace,
|
||||
onCancel,
|
||||
onSubmit
|
||||
}) => {
|
||||
const formRef = useRef<any>(null);
|
||||
|
||||
const handleSubmit = () => {
|
||||
formRef.current?.submit();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
formRef.current?.resetFields();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleFinish = async (values: StorageFormData) => {
|
||||
await onSubmit(values);
|
||||
};
|
||||
|
||||
const getOverlayContainer = useCallback(() => {
|
||||
const containers = document.querySelectorAll<HTMLElement>(
|
||||
'.ant-layout-content'
|
||||
);
|
||||
|
||||
return containers[containers.length - 1] ?? null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FormOverlayView
|
||||
title="添加存储"
|
||||
open={open}
|
||||
width={600}
|
||||
onCancel={handleCancel}
|
||||
getContainer={getOverlayContainer}
|
||||
footer={
|
||||
<ModalFooter
|
||||
onOk={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
style={{
|
||||
padding: '16px 24px 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<GPUServiceStorageForm
|
||||
ref={formRef}
|
||||
action={PageAction.CREATE}
|
||||
open={open}
|
||||
namespace={namespace}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</FormOverlayView>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageOverlay;
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useModel } from '@@/plugin-model';
|
||||
import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui';
|
||||
import { Button, Flex, Form, Radio } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Flex, Form, message, Radio } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { mockStorageData } from '../../storage/config/mock-data';
|
||||
import { FormData as StorageFormData } from '../../storage/config/types';
|
||||
import useCreateStorage from '../../storage/services/use-create-storage';
|
||||
import useQueryStorage from '../../storage/services/use-query-storage';
|
||||
import { StorageModeValueMap } from '../config';
|
||||
import { FormData } from '../config/types';
|
||||
import StorageOverlay from './storage-overlay';
|
||||
|
||||
const FieldBlock = styled.div`
|
||||
margin-bottom: 24px;
|
||||
@@ -17,6 +21,9 @@ type StorageHolderFields = {
|
||||
};
|
||||
|
||||
const StorageVolume = () => {
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const namespace = initialState?.currentUser?.org_name || 'default';
|
||||
|
||||
const form = Form.useFormInstance<FormData & StorageHolderFields>();
|
||||
const storageMode = Form.useWatch('storage_mode', form) as string | undefined;
|
||||
const storageName = Form.useWatch('storage_name', form) as string | undefined;
|
||||
@@ -24,6 +31,24 @@ const StorageVolume = () => {
|
||||
| number
|
||||
| undefined;
|
||||
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
|
||||
const { fetchData: createStorage } = useCreateStorage();
|
||||
const { detailData: storageData, fetchData: fetchStorage } = useQueryStorage();
|
||||
|
||||
useEffect(() => {
|
||||
fetchStorage({});
|
||||
}, [fetchStorage]);
|
||||
|
||||
const storageOptions = useMemo(
|
||||
() =>
|
||||
(storageData?.items || []).map((item) => ({
|
||||
label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`,
|
||||
value: item.metadata?.name
|
||||
})),
|
||||
[storageData]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (storageMode === StorageModeValueMap.Existing) {
|
||||
form.setFieldValue(['spec', 'volume'], {
|
||||
@@ -38,6 +63,18 @@ const StorageVolume = () => {
|
||||
}
|
||||
}, [form, storageMode, storageName, localSize]);
|
||||
|
||||
const handleCreateStorage = async (values: StorageFormData) => {
|
||||
try {
|
||||
await createStorage({ data: values });
|
||||
await fetchStorage({});
|
||||
form.setFieldValue('storage_name', values.metadata.name);
|
||||
setOverlayOpen(false);
|
||||
message.success('操作成功');
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldBlock data-field="storage">
|
||||
<Flex
|
||||
@@ -64,7 +101,12 @@ const StorageVolume = () => {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="link" size="small" style={{ marginBottom: 6 }}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ marginBottom: 6 }}
|
||||
onClick={() => setOverlayOpen(true)}
|
||||
>
|
||||
添加存储
|
||||
</Button>
|
||||
</Flex>
|
||||
@@ -79,14 +121,7 @@ const StorageVolume = () => {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
label="持久卷"
|
||||
required
|
||||
options={mockStorageData.map((item) => ({
|
||||
label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`,
|
||||
value: item.metadata?.name
|
||||
}))}
|
||||
/>
|
||||
<Select label="持久卷" required options={storageOptions} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
@@ -103,6 +138,13 @@ const StorageVolume = () => {
|
||||
<CInputNumber min={1} precision={0} label="存储容量 (GB)" required />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<StorageOverlay
|
||||
open={overlayOpen}
|
||||
namespace={namespace}
|
||||
onCancel={() => setOverlayOpen(false)}
|
||||
onSubmit={handleCreateStorage}
|
||||
/>
|
||||
</FieldBlock>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import FormOverlayView from '@/pages/_components/form-overlay-view';
|
||||
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { Button, Empty, Flex, Input } from 'antd';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
||||
import TemplateSelector from './template-selector';
|
||||
|
||||
const DrawerBody = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`;
|
||||
|
||||
interface TemplateOverlayProps {
|
||||
open: boolean;
|
||||
value?: number;
|
||||
onCancel: () => void;
|
||||
onChange?: (value: number) => void;
|
||||
onCreate?: () => void;
|
||||
}
|
||||
|
||||
const TemplateOverlay: React.FC<TemplateOverlayProps> = ({
|
||||
open,
|
||||
value,
|
||||
onCancel,
|
||||
onChange,
|
||||
onCreate
|
||||
}) => {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const currentKeyword = keyword.trim().toLowerCase();
|
||||
if (!currentKeyword) {
|
||||
return mockTemplateData;
|
||||
}
|
||||
|
||||
return mockTemplateData.filter((item) => {
|
||||
return [item.name, item.image, item.volumeMount].some((text) =>
|
||||
(text || '').toLowerCase().includes(currentKeyword)
|
||||
);
|
||||
});
|
||||
}, [keyword]);
|
||||
|
||||
const handleChange = (id: number) => {
|
||||
onChange?.(id);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const getOverlayContainer = useCallback(() => {
|
||||
const containers = document.querySelectorAll<HTMLElement>(
|
||||
'.ant-layout-content'
|
||||
);
|
||||
|
||||
return containers[containers.length - 1] ?? null;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FormOverlayView
|
||||
title="选择实例模板"
|
||||
open={open}
|
||||
width={600}
|
||||
onCancel={onCancel}
|
||||
getContainer={getOverlayContainer}
|
||||
footer={false}
|
||||
>
|
||||
<DrawerBody>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined className="text-tertiary" />}
|
||||
placeholder="搜索模板名称、镜像或挂载路径"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreate}>
|
||||
添加模板
|
||||
</Button>
|
||||
</Flex>
|
||||
{filteredTemplates.length > 0 ? (
|
||||
<TemplateSelector
|
||||
value={value}
|
||||
dataList={filteredTemplates}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
</DrawerBody>
|
||||
</FormOverlayView>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateOverlay;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
|
||||
import styled from 'styled-components';
|
||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||
|
||||
const TemplateGrid = styled.div`
|
||||
@@ -43,14 +42,14 @@ const TemplateContent = styled.div`
|
||||
|
||||
interface TemplateSelectorProps {
|
||||
value?: number;
|
||||
onChange?: (value: number) => void;
|
||||
onChange?: (value: number, item: TemplateItem) => void;
|
||||
dataList?: TemplateItem[];
|
||||
}
|
||||
|
||||
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
dataList = mockTemplateData
|
||||
dataList = []
|
||||
}) => {
|
||||
return (
|
||||
<TemplateGrid>
|
||||
@@ -63,7 +62,7 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
height={102}
|
||||
active={value === item.id}
|
||||
disabled={item.status !== 'enabled'}
|
||||
onClick={() => onChange?.(item.id)}
|
||||
onClick={() => onChange?.(item.id, item)}
|
||||
>
|
||||
<TemplateContent>
|
||||
<div className="name">
|
||||
@@ -76,14 +75,14 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
<IconFont className="icon" type="icon-model" /> 镜像:
|
||||
</span>
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
<span className="value">{item.image || '-'}</span>
|
||||
<span className="value">{item.spec?.image || '-'}</span>
|
||||
</AutoTooltip>
|
||||
</div>
|
||||
<div className="info">
|
||||
<span>
|
||||
<IconFont className="icon" type="icon-storage-outlined" /> 挂载:
|
||||
</span>
|
||||
<span className="value">{item.volumeMount || '-'}</span>
|
||||
<span className="value">{item.spec?.volumeMount || '-'}</span>
|
||||
</div>
|
||||
</TemplateContent>
|
||||
</TemplateCard>
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { PageAction } from '@/config';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import { AutoTooltip, Input as CInput } from '@gpustack/core-ui';
|
||||
import { Button, Flex, Form, message } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
createGPUServiceTemplate,
|
||||
updateGPUServiceTemplate
|
||||
} from '../../templates/apis';
|
||||
import AddTemplateModal from '../../templates/components/add-modal';
|
||||
import { mockTemplateData } from '../../templates/config/mock-data';
|
||||
import {
|
||||
FormData as TemplateFormData,
|
||||
ListItem as TemplateListItem
|
||||
} from '../../templates/config/types';
|
||||
import useCreateTemplate from '../../templates/hooks/use-create-template';
|
||||
import { FormData } from '../config/types';
|
||||
import TemplateOverlay from './template-overlay';
|
||||
|
||||
const FieldBlock = styled.div`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const SelectedCard = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: var(--ant-border-radius-lg);
|
||||
background: var(--ant-color-bg-container);
|
||||
`;
|
||||
|
||||
const SummaryTitle = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ant-color-text);
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const SummaryMeta = styled.div`
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
interface TemplateSelectorProps {
|
||||
value?: number;
|
||||
onChange?: (value: number) => void;
|
||||
onCreate?: () => void;
|
||||
onEdit?: (row: TemplateListItem) => void;
|
||||
}
|
||||
|
||||
const TemplatePicker: React.FC<TemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onCreate,
|
||||
onEdit
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = useMemo(() => {
|
||||
return mockTemplateData.find((item) => item.id === value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectedCard>
|
||||
<div>
|
||||
<SummaryTitle>
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
{selected?.name || '请选择实例模板'}
|
||||
</AutoTooltip>
|
||||
</SummaryTitle>
|
||||
<SummaryMeta>
|
||||
<AutoTooltip ghost minWidth={20}>
|
||||
<span>镜像: {selected?.image || '-'}</span>
|
||||
</AutoTooltip>
|
||||
<span>挂载: {selected?.volumeMount || '-'}</span>
|
||||
</SummaryMeta>
|
||||
</div>
|
||||
<Flex gap={8}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
disabled={!selected}
|
||||
onClick={() => selected && onEdit?.(selected)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(true)}>更换</Button>
|
||||
</Flex>
|
||||
</SelectedCard>
|
||||
<TemplateOverlay
|
||||
open={open}
|
||||
value={value}
|
||||
onCancel={() => setOpen(false)}
|
||||
onChange={onChange}
|
||||
onCreate={() => {
|
||||
onCreate?.();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateFormItem = () => {
|
||||
const form = Form.useFormInstance<FormData & { template_id?: number }>();
|
||||
const templateId = Form.useWatch('template_id', form) as number | undefined;
|
||||
const { openTemplateModalStatus, openTemplateModal, closeTemplateModal } =
|
||||
useCreateTemplate();
|
||||
|
||||
const selectedTemplate = useMemo(() => {
|
||||
return mockTemplateData.find((item) => item.id === templateId);
|
||||
}, [templateId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateId && mockTemplateData[0]) {
|
||||
form.setFieldValue('template_id', mockTemplateData[0].id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTemplate) {
|
||||
form.setFieldValue(['spec', 'image'], selectedTemplate.image);
|
||||
form.setFieldValue(['spec', 'command'], selectedTemplate.command || []);
|
||||
form.setFieldValue(['spec', 'ports'], selectedTemplate.ports || []);
|
||||
form.setFieldValue(['spec', 'env'], selectedTemplate.env || []);
|
||||
form.setFieldValue(['spec', 'volumeMount'], selectedTemplate.volumeMount);
|
||||
const currentResources = form.getFieldValue(['spec', 'resources']) || {};
|
||||
form.setFieldValue(['spec', 'resources'], {
|
||||
...currentResources,
|
||||
cpu: selectedTemplate.resources?.cpu,
|
||||
ram: selectedTemplate.resources?.ram
|
||||
});
|
||||
}
|
||||
}, [form, selectedTemplate, templateId]);
|
||||
|
||||
const handleAddTemplate = () => {
|
||||
openTemplateModal(PageAction.CREATE, '添加实例模板');
|
||||
};
|
||||
|
||||
const handleEditTemplate = (row: TemplateListItem) => {
|
||||
openTemplateModal(PageAction.EDIT, '编辑实例模板', row);
|
||||
};
|
||||
|
||||
const handleTemplateModalOk = async (data: TemplateFormData) => {
|
||||
try {
|
||||
if (openTemplateModalStatus.action === PageAction.EDIT) {
|
||||
await updateGPUServiceTemplate({
|
||||
id: openTemplateModalStatus.currentData!.id,
|
||||
data
|
||||
});
|
||||
} else {
|
||||
await createGPUServiceTemplate({ data });
|
||||
}
|
||||
closeTemplateModal();
|
||||
message.success('操作成功');
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FieldBlock data-field="template">
|
||||
<Form.Item
|
||||
name="template_id"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择实例模板'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<TemplatePicker
|
||||
onCreate={handleAddTemplate}
|
||||
onEdit={handleEditTemplate}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FieldBlock>
|
||||
|
||||
<Form.Item<FormData> name={['spec', 'image']} hidden>
|
||||
<CInput.Input />
|
||||
</Form.Item>
|
||||
|
||||
<AddTemplateModal
|
||||
action={openTemplateModalStatus.action}
|
||||
open={openTemplateModalStatus.open}
|
||||
title={openTemplateModalStatus.title}
|
||||
currentData={openTemplateModalStatus.currentData}
|
||||
onCancel={closeTemplateModal}
|
||||
onOk={handleTemplateModalOk}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateFormItem;
|
||||
Reference in New Issue
Block a user