fix: ssh key, template test

This commit is contained in:
jialin
2026-05-11 19:49:21 +08:00
committed by jialin
parent 63ac4e0e75
commit fc900c8ce6
45 changed files with 1027 additions and 1090 deletions
@@ -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[];
+31 -63
View File
@@ -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;