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', name: 'gpuService',
path: '/gpu-service', path: '/gpu-service',
key: 'gpuService', key: 'gpuService',
access: 'canSeeAdmin',
routes: [ routes: [
{ {
path: '/gpu-service', path: '/gpu-service',
+10
View File
@@ -46,7 +46,11 @@ export default {
'gpuservice.storageType.kind': 'Type', 'gpuservice.storageType.kind': 'Type',
'gpuservice.storageType.mountOptions': 'Mount Options', 'gpuservice.storageType.mountOptions': 'Mount Options',
'gpuservice.storageType.nfs.server': 'NFS Server', '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': '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': 'Sub Directory',
'gpuservice.storageType.nfs.subDirectory.tips': '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.', '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': 'gpuservice.storageType.nfs.mountPermissions.tips':
'Inherit the file permissions from the NFS server.', 'Inherit the file permissions from the NFS server.',
'gpuservice.storageType.s3.endpoint': 'Endpoint', '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.endpoint.rule': 'Must start with http or https',
'gpuservice.storageType.s3.region': 'Region', 'gpuservice.storageType.s3.region': 'Region',
'gpuservice.storageType.s3.bucket': 'Bucket', 'gpuservice.storageType.s3.bucket': 'Bucket',
'gpuservice.storageType.s3.bucket.tips': '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.', '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.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key', 'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': 'Skip TLS/SSL certificate verification', 'gpuservice.storageType.s3.insecure': 'Skip TLS/SSL certificate verification',
+10
View File
@@ -44,7 +44,11 @@ export default {
'gpuservice.storageType.kind': '種別', 'gpuservice.storageType.kind': '種別',
'gpuservice.storageType.mountOptions': 'マウントオプション', 'gpuservice.storageType.mountOptions': 'マウントオプション',
'gpuservice.storageType.nfs.server': 'NFS サーバー', 'gpuservice.storageType.nfs.server': 'NFS サーバー',
'gpuservice.storageType.nfs.server.tips':
'すべての Kubernetes クラスターから NFS サーバーアドレスにアクセスできることを確認してください。',
'gpuservice.storageType.nfs.share': '共有パス', 'gpuservice.storageType.nfs.share': '共有パス',
'gpuservice.storageType.nfs.share.tips':
'この共有パス配下に、組織名とストレージ名に基づくディレクトリが自動的に作成されます。サブディレクトリが指定されている場合、生成されたディレクトリはそのサブディレクトリ配下に作成されます。',
'gpuservice.storageType.nfs.subDirectory': 'サブディレクトリ', 'gpuservice.storageType.nfs.subDirectory': 'サブディレクトリ',
'gpuservice.storageType.nfs.subDirectory.tips': 'gpuservice.storageType.nfs.subDirectory.tips':
'空の場合、永続ボリューム名のサブディレクトリが作成されます。設定されている場合、このサブディレクトリ配下に永続ボリューム名のディレクトリが作成されます。', '空の場合、永続ボリューム名のサブディレクトリが作成されます。設定されている場合、このサブディレクトリ配下に永続ボリューム名のディレクトリが作成されます。',
@@ -52,12 +56,18 @@ export default {
'gpuservice.storageType.nfs.mountPermissions.tips': 'gpuservice.storageType.nfs.mountPermissions.tips':
'NFS サーバー上のファイル権限を継承します。', 'NFS サーバー上のファイル権限を継承します。',
'gpuservice.storageType.s3.endpoint': 'エンドポイント', 'gpuservice.storageType.s3.endpoint': 'エンドポイント',
'gpuservice.storageType.s3.endpoint.tips':
'すべての Kubernetes クラスターから S3 エンドポイントにアクセスできることを確認してください。',
'gpuservice.storageType.s3.endpoint.rule': 'gpuservice.storageType.s3.endpoint.rule':
'http または https で始まる必要があります', 'http または https で始まる必要があります',
'gpuservice.storageType.s3.region': 'リージョン', 'gpuservice.storageType.s3.region': 'リージョン',
'gpuservice.storageType.s3.bucket': 'バケット', 'gpuservice.storageType.s3.bucket': 'バケット',
'gpuservice.storageType.s3.bucket.tips': '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.accessKey': 'アクセスキー',
'gpuservice.storageType.s3.secretKey': 'シークレットキー', 'gpuservice.storageType.s3.secretKey': 'シークレットキー',
'gpuservice.storageType.s3.insecure': 'TLS/SSL 証明書の検証をスキップ', 'gpuservice.storageType.s3.insecure': 'TLS/SSL 証明書の検証をスキップ',
+12 -1
View File
@@ -47,7 +47,11 @@ export default {
'gpuservice.storageType.kind': 'Тип', 'gpuservice.storageType.kind': 'Тип',
'gpuservice.storageType.mountOptions': 'Параметры монтирования', 'gpuservice.storageType.mountOptions': 'Параметры монтирования',
'gpuservice.storageType.nfs.server': 'Сервер NFS', 'gpuservice.storageType.nfs.server': 'Сервер NFS',
'gpuservice.storageType.nfs.server.tips':
'Убедитесь, что адрес NFS-сервера доступен из всех кластеров Kubernetes.',
'gpuservice.storageType.nfs.share': 'Путь общего ресурса', 'gpuservice.storageType.nfs.share': 'Путь общего ресурса',
'gpuservice.storageType.nfs.share.tips':
'В этом общем пути будет автоматически создан каталог на основе названия организации и названия хранилища. Если указан подкаталог, итоговый каталог будет создан внутри него.',
'gpuservice.storageType.nfs.subDirectory': 'Подкаталог', 'gpuservice.storageType.nfs.subDirectory': 'Подкаталог',
'gpuservice.storageType.nfs.subDirectory.tips': 'gpuservice.storageType.nfs.subDirectory.tips':
'Если поле пустое, будет создан подкаталог с именем постоянного тома. Если задано, под этим подкаталогом будет создан каталог с именем постоянного тома.', 'Если поле пустое, будет создан подкаталог с именем постоянного тома. Если задано, под этим подкаталогом будет создан каталог с именем постоянного тома.',
@@ -55,15 +59,22 @@ export default {
'gpuservice.storageType.nfs.mountPermissions.tips': 'gpuservice.storageType.nfs.mountPermissions.tips':
'Наследует права файлов с NFS-сервера.', 'Наследует права файлов с NFS-сервера.',
'gpuservice.storageType.s3.endpoint': 'Endpoint', 'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.tips':
'Убедитесь, что S3 endpoint доступен из всех кластеров Kubernetes.',
'gpuservice.storageType.s3.endpoint.rule': 'gpuservice.storageType.s3.endpoint.rule':
'Должен начинаться с http или https', 'Должен начинаться с http или https',
'gpuservice.storageType.s3.region': 'Регион', 'gpuservice.storageType.s3.region': 'Регион',
'gpuservice.storageType.s3.bucket': 'Бакет', 'gpuservice.storageType.s3.bucket': 'Бакет',
'gpuservice.storageType.s3.bucket.tips': '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.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key', 'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': 'Пропустить проверку сертификата TLS/SSL', 'gpuservice.storageType.s3.insecure':
'Пропустить проверку сертификата TLS/SSL',
'gpuservice.storageType.s3.insecure.tips': 'gpuservice.storageType.s3.insecure.tips':
'Если включено, сертификат сервера S3 не проверяется. Подходит для внутреннего тестирования или самоподписанных сертификатов; в производственной среде включайте с осторожностью.', 'Если включено, сертификат сервера S3 не проверяется. Подходит для внутреннего тестирования или самоподписанных сертификатов; в производственной среде включайте с осторожностью.',
'gpuservice.publicKey': 'Открытый ключ SSH', 'gpuservice.publicKey': 'Открытый ключ SSH',
+11 -2
View File
@@ -46,7 +46,11 @@ export default {
'gpuservice.storageType.kind': 'Tür', 'gpuservice.storageType.kind': 'Tür',
'gpuservice.storageType.mountOptions': 'Bağlama Seçenekleri', 'gpuservice.storageType.mountOptions': 'Bağlama Seçenekleri',
'gpuservice.storageType.nfs.server': 'NFS Sunucusu', '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': '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': 'Alt Dizin',
'gpuservice.storageType.nfs.subDirectory.tips': '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.', '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': 'gpuservice.storageType.nfs.mountPermissions.tips':
'NFS sunucusundaki dosya izinleri devralınır.', 'NFS sunucusundaki dosya izinleri devralınır.',
'gpuservice.storageType.s3.endpoint': 'Endpoint', 'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.rule': 'gpuservice.storageType.s3.endpoint.tips':
'http veya https ile başlamalıdır', '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.region': 'Bölge',
'gpuservice.storageType.s3.bucket': 'Kova', 'gpuservice.storageType.s3.bucket': 'Kova',
'gpuservice.storageType.s3.bucket.tips': '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.', '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.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key', 'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': 'TLS/SSL sertifika doğrulamasını atla', '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.kind': '类型',
'gpuservice.storageType.mountOptions': '挂载参数', 'gpuservice.storageType.mountOptions': '挂载参数',
'gpuservice.storageType.nfs.server': 'NFS 服务器', 'gpuservice.storageType.nfs.server': 'NFS 服务器',
'gpuservice.storageType.nfs.server.tips':
'确保所有 Kubernetes 集群都能访问该 NFS 服务地址。',
'gpuservice.storageType.nfs.share': '共享路径', 'gpuservice.storageType.nfs.share': '共享路径',
'gpuservice.storageType.nfs.share.tips':
'系统会在该共享路径下自动创建一个基于组织名称和存储名称的目录。如果指定了子目录,则生成的目录会创建在该子目录下。',
'gpuservice.storageType.nfs.subDirectory': '子目录', 'gpuservice.storageType.nfs.subDirectory': '子目录',
'gpuservice.storageType.nfs.subDirectory.tips': 'gpuservice.storageType.nfs.subDirectory.tips':
'如果为空,会以持久卷的卷名创建一个子目录;如果不为空,会在同名子目录下,以持久卷的卷名创建一个孙目录。', '如果为空,会以持久卷的卷名创建一个子目录;如果不为空,会在同名子目录下,以持久卷的卷名创建一个孙目录。',
@@ -49,11 +53,17 @@ export default {
'gpuservice.storageType.nfs.mountPermissions.tips': 'gpuservice.storageType.nfs.mountPermissions.tips':
'沿用 NFS 服务器文件的权限。', '沿用 NFS 服务器文件的权限。',
'gpuservice.storageType.s3.endpoint': 'Endpoint', 'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.tips':
'确保所有 Kubernetes 集群都能访问该 S3 Endpoint。',
'gpuservice.storageType.s3.endpoint.rule': '必须以 http 或 https 开头', 'gpuservice.storageType.s3.endpoint.rule': '必须以 http 或 https 开头',
'gpuservice.storageType.s3.region': '区域', 'gpuservice.storageType.s3.region': '区域',
'gpuservice.storageType.s3.bucket': '存储桶', 'gpuservice.storageType.s3.bucket': '存储桶',
'gpuservice.storageType.s3.bucket.tips': '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.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key', 'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': '跳过 TLS/SSL 证书验证', 'gpuservice.storageType.s3.insecure': '跳过 TLS/SSL 证书验证',
@@ -31,13 +31,6 @@ type AddModalProps = {
onCancel: () => void; onCancel: () => void;
}; };
type InstanceTypeSelection = {
instanceType?: string;
manufacturer?: string;
};
const EMPTY_INSTANCE_TYPE_SELECTION: InstanceTypeSelection = {};
const matchKeyword = (fields: Array<unknown>, keyword: string) => { const matchKeyword = (fields: Array<unknown>, keyword: string) => {
const trimmed = keyword.trim().toLowerCase(); const trimmed = keyword.trim().toLowerCase();
if (!trimmed) return true; if (!trimmed) return true;
@@ -85,11 +78,17 @@ const AddModal: React.FC<AddModalProps> = ({
const intl = useIntl(); const intl = useIntl();
const form = useRef<any>(null); const form = useRef<any>(null);
const sessionRef = useRef(0); const sessionRef = useRef(0);
const [instanceTypeSelection, setInstanceTypeSelection] = const [instanceTypeSelection, setInstanceTypeSelection] = useState<{
useState<InstanceTypeSelection>(EMPTY_INSTANCE_TYPE_SELECTION); instanceType?: string;
manufacturer?: string;
}>({
instanceType: undefined,
manufacturer: undefined
});
const [templateId, setTemplateId] = useState<number | undefined>(); const [templateId, setTemplateId] = useState<number | undefined>();
const [instanceKeyword, setInstanceKeyword] = useState(''); const [instanceKeyword, setInstanceKeyword] = useState('');
const [templateKeyword, setTemplateKeyword] = useState(''); const [templateKeyword, setTemplateKeyword] = useState('');
const [loading, setLoading] = useState(false);
const { const {
detailData, detailData,
@@ -116,6 +115,20 @@ const AddModal: React.FC<AddModalProps> = ({
: undefined; : 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 = ( const applySelection = (
instanceType: InstanceTypeItem, instanceType: InstanceTypeItem,
template: TemplateItem | undefined template: TemplateItem | undefined
@@ -126,37 +139,29 @@ const AddModal: React.FC<AddModalProps> = ({
instanceType: instanceType.name, instanceType: instanceType.name,
manufacturer manufacturer
}); });
setTemplateId(template?.id); setTemplateId(template?.id);
if (template) { if (template) {
const currentSpec = form.current?.getFieldsValue()?.spec || {}; const formValues = form.current?.getFieldsValue();
form.current?.setFieldsValue({ form.current?.setFieldsValue({
manufacturer: template.manufacturer, description: saveInstanceDataInDescription(instanceType),
description: JSON.stringify(
{
name: instanceType.name,
spec: {
...instanceType.spec
}
},
null,
2
),
spec: { spec: {
...currentSpec, ...formValues?.spec,
...template.spec, ...template.spec,
resources: { sshPublicKeys: formValues?.spec?.sshPublicKeys,
...(currentSpec.resources || {}), volume: {
...(template.spec?.resources || {}) ...formValues?.spec?.volume
} }
} }
}); });
} else { } else {
form.current?.setFieldsValue({ form.current?.setFieldsValue({
manufacturer: undefined, description: saveInstanceDataInDescription(instanceType)
description: JSON.stringify(instanceType.spec, null, 2)
}); });
} }
// update form
form.current?.applyInstanceType?.(instanceType); form.current?.applyInstanceType?.(instanceType);
}; };
@@ -175,6 +180,7 @@ const AddModal: React.FC<AddModalProps> = ({
); );
}; };
// initial
const applyAutoSelection = ( const applyAutoSelection = (
instanceTypes: InstanceTypeItem[], instanceTypes: InstanceTypeItem[],
templates: TemplateItem[] templates: TemplateItem[]
@@ -208,7 +214,10 @@ const AddModal: React.FC<AddModalProps> = ({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
sessionRef.current += 1; sessionRef.current += 1;
setInstanceTypeSelection(EMPTY_INSTANCE_TYPE_SELECTION); setInstanceTypeSelection({
instanceType: undefined,
manufacturer: undefined
});
setTemplateId(undefined); setTemplateId(undefined);
setInstanceKeyword(''); setInstanceKeyword('');
setTemplateKeyword(''); setTemplateKeyword('');
@@ -240,6 +249,7 @@ const AddModal: React.FC<AddModalProps> = ({
) { ) {
return false; return false;
} }
return matchKeyword( return matchKeyword(
[item.name, item.spec?.image, item.spec?.volumeMount], [item.name, item.spec?.image, item.spec?.volumeMount],
templateKeyword templateKeyword
@@ -256,9 +266,14 @@ const AddModal: React.FC<AddModalProps> = ({
}; };
const onFinish = async (values: FormData) => { const onFinish = async (values: FormData) => {
onOk({ setLoading(true);
...values try {
}); await onOk({
...values
});
} finally {
setLoading(false);
}
}; };
const handleInstanceTypeChange = (item: InstanceTypeItem) => { const handleInstanceTypeChange = (item: InstanceTypeItem) => {
@@ -271,10 +286,19 @@ const AddModal: React.FC<AddModalProps> = ({
const handleTemplateChange = (id: number, item: TemplateItem) => { const handleTemplateChange = (id: number, item: TemplateItem) => {
setTemplateId(id); setTemplateId(id);
const formValues = form.current?.getFieldsValue();
form.current?.setFieldsValue({ form.current?.setFieldsValue({
spec: { spec: {
...form.current?.getFieldsValue()?.spec, ...formValues?.spec,
...item.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} onOk={handleSubmit}
onCancel={handleCancel} onCancel={handleCancel}
showOkBtn={!readonly} showOkBtn={!readonly}
loading={loading}
style={{ style={{
padding: '16px 24px 8px', padding: '16px 24px 8px',
display: 'flex', display: 'flex',
@@ -34,7 +34,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
loading loading
}) => { }) => {
const handleSelect = (item: InstanceTypeItemModel) => { const handleSelect = (item: InstanceTypeItemModel) => {
if (!isAvailable(item)) return; if (!isAvailable(item) || value === item.name) return;
onChange?.(item); onChange?.(item);
}; };
@@ -19,6 +19,7 @@ type ViewEventsModalProps = {
name: string; name: string;
namespace: string; namespace: string;
clusterID?: number; clusterID?: number;
hasPersistentVolume?: boolean;
onCancel: () => void; onCancel: () => void;
}; };
@@ -31,7 +32,8 @@ const eventTypeStatus: Record<string, 'success' | 'warning' | 'error'> = {
const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => { const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
const intl = useIntl(); const intl = useIntl();
const { open, onCancel, name, namespace, clusterID } = props || {}; const { open, onCancel, name, namespace, clusterID, hasPersistentVolume } =
props || {};
const [activeKey, setActiveKey] = useState('instance'); const [activeKey, setActiveKey] = useState('instance');
const { const {
@@ -54,7 +56,9 @@ const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
const refreshAll = () => { const refreshAll = () => {
if (!name || !namespace || !clusterID) return; if (!name || !namespace || !clusterID) return;
fetchInstanceEvents({ name, namespace, clusterID }); fetchInstanceEvents({ name, namespace, clusterID });
fetchVolumeEvents({ name, namespace, clusterID }); if (hasPersistentVolume) {
fetchVolumeEvents({ name, namespace, clusterID });
}
}; };
useEffect(() => { useEffect(() => {
@@ -184,16 +188,20 @@ const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
}), }),
children: renderTable(instanceEvents, instanceLoading) children: renderTable(instanceEvents, instanceLoading)
}, },
{ ...(hasPersistentVolume
key: 'volume', ? [
label: intl.formatMessage({ {
id: 'gpuservice.instance.event.tab.volume' key: 'volume',
}), label: intl.formatMessage({
children: renderTable(volumeEvents, volumeLoading) id: 'gpuservice.instance.event.tab.volume'
} }),
children: renderTable(volumeEvents, volumeLoading)
}
]
: [])
]; ];
const isLoading = instanceLoading || volumeLoading; const isLoading = instanceLoading || (hasPersistentVolume && volumeLoading);
return ( return (
<ScrollerModal <ScrollerModal
+24 -87
View File
@@ -17,6 +17,7 @@ import {
import { useIntl, useNavigate } from '@umijs/max'; import { useIntl, useNavigate } from '@umijs/max';
import { Button, Empty, Form } from 'antd'; import { Button, Empty, Form } from 'antd';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import _ from 'lodash';
import { import {
forwardRef, forwardRef,
useEffect, useEffect,
@@ -229,6 +230,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
instanceType.status?.acceleratorTiers, instanceType.status?.acceleratorTiers,
count count
); );
setSelectedInstanceType(instanceType); setSelectedInstanceType(instanceType);
setOnceMaxRequest({ setOnceMaxRequest({
cpu: parseQuantityToNumber(candidate?.cpu?.onceMaxRequest), cpu: parseQuantityToNumber(candidate?.cpu?.onceMaxRequest),
@@ -237,11 +239,11 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}); });
form.setFieldsValue({ form.setFieldsValue({
clusterId: candidate?.cluster ? Number(candidate.cluster) : null, clusterId: candidate?.cluster ? _.toNumber(candidate.cluster) : null,
spec: { spec: {
type: candidate?.name || '', type: candidate?.name || '',
...(options.writeAccelerator ...(options.writeAccelerator
? { resources: { accelerator: count } } ? { resources: { accelerator: _.toString(count) } }
: {}) : {})
} as any } as any
}); });
@@ -278,91 +280,23 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
} }
if ( if (
currentData && action === PageAction.EDIT ||
(action === PageAction.EDIT || action === PageAction.VIEW ||
action === PageAction.VIEW || realAction === PageAction.CREATE
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({ form.setFieldsValue({
name: currentData.name, ...currentData,
displayName: currentData.displayName, enable_ssh: !!currentData?.spec?.sshPublicKeys?.length
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
}); });
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]); }, [action, currentData, form, open, realAction, instanceTypeList]);
const handleFinish = async (values: InstanceFormValues) => { 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 submittedPorts = [...(values.spec?.ports ?? [])];
const submittedHasSSHPort = submittedPorts.some( const submittedHasSSHPort = submittedPorts.some(
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT (item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
); );
if (values.enable_ssh && !submittedHasSSHPort) { if (values.enable_ssh && !submittedHasSSHPort) {
submittedPorts.push({ submittedPorts.push({
protocol: 'TCP', protocol: 'TCP',
@@ -370,17 +304,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
name: 'SSH' name: 'SSH'
}); });
} }
console.log('submit values', values, submittedPorts);
await onFinish({ await onFinish({
...values, ..._.omit(values, ['enable_ssh']),
spec: { spec: {
...values.spec, ...values.spec,
ports: submittedPorts, ports: submittedPorts
resources: normalizedResources,
volume: normalizedVolume,
sshPublicKeys: values.enable_ssh
? (selectedKeys ?? []).map((name) => ({ name }))
: []
} }
}); });
}; };
@@ -451,7 +380,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}, },
volume: { volume: {
ephemeral: { ephemeral: {
capacity: '20Gi' capacity: '50Gi'
}, },
persistent: { persistent: {
name: '' name: ''
@@ -462,8 +391,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}} }}
> >
<Basic action={formAction} disabled={disabled} /> <Basic action={formAction} disabled={disabled} />
{/* Hidden form field: the candidate.cluster resolved from the
selected aggregate + accelerator count. */}
<Form.Item name="clusterId" hidden> <Form.Item name="clusterId" hidden>
<CInput.Input /> <CInput.Input />
</Form.Item> </Form.Item>
@@ -537,6 +464,16 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
style={{ style={{
marginBottom: 12 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={[ rules={[
{ {
required: true, required: true,
@@ -79,13 +79,6 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
return selectedInstanceType?.spec?.acceleratable; return selectedInstanceType?.spec?.acceleratable;
}, [selectedInstanceType, action]); }, [selectedInstanceType, action]);
const selectedInstanceData = useMemo(() => {
if (action === PageAction.EDIT) {
return JSON.parse(currentData?.description || '{}') || {};
}
return selectedInstanceType;
}, [selectedInstanceType, action, currentData]);
const renderInstanceType = () => { const renderInstanceType = () => {
const description = JSON.parse(currentData?.description || '{}').spec || {}; const description = JSON.parse(currentData?.description || '{}').spec || {};
return ( return (
@@ -130,6 +123,10 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
<Form.Item<FormData> <Form.Item<FormData>
name={['spec', 'resources', 'accelerator']} name={['spec', 'resources', 'accelerator']}
hidden={action === PageAction.EDIT} hidden={action === PageAction.EDIT}
normalize={(value) => (value ? _.toString(value) : undefined)}
getValueProps={(value) => ({
value: value ? _.toNumber(value) : undefined
})}
rules={[ rules={[
{ {
required: true, required: true,
@@ -1,8 +1,10 @@
import { PageAction } from '@/config'; import { PageAction } from '@/config';
import FormOverlayView from '@/pages/_components/form-overlay-view'; 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 { ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; 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 { FormData as StorageFormData } from '../../storage/config/types';
import GPUServiceStorageForm from '../../storage/forms'; import GPUServiceStorageForm from '../../storage/forms';
@@ -18,8 +20,17 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
onSubmit onSubmit
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { storageClassList, fetchData: fetchStorageClass } =
useQueryStorageClass();
const [loading, setLoading] = useState(false);
const formRef = useRef<any>(null); const formRef = useRef<any>(null);
useEffect(() => {
if (open) {
fetchStorageClass({ page: -1 });
}
}, [open]);
const handleSubmit = () => { const handleSubmit = () => {
formRef.current?.submit(); formRef.current?.submit();
}; };
@@ -30,7 +41,12 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
}; };
const handleFinish = async (values: StorageFormData) => { const handleFinish = async (values: StorageFormData) => {
await onSubmit(values); setLoading(true);
try {
await onSubmit(values);
} finally {
setLoading(false);
}
}; };
const getOverlayContainer = useCallback(() => { const getOverlayContainer = useCallback(() => {
@@ -52,6 +68,7 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
<ModalFooter <ModalFooter
onOk={handleSubmit} onOk={handleSubmit}
onCancel={handleCancel} onCancel={handleCancel}
loading={loading}
style={{ style={{
padding: '16px 24px 24px', padding: '16px 24px 24px',
display: 'flex', display: 'flex',
@@ -60,12 +77,14 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
/> />
} }
> >
<GPUServiceStorageForm <FormContext.Provider value={{ storageClassList }}>
ref={formRef} <GPUServiceStorageForm
action={PageAction.CREATE} ref={formRef}
open={open} action={PageAction.CREATE}
onFinish={handleFinish} open={open}
/> onFinish={handleFinish}
/>
</FormContext.Provider>
</FormOverlayView> </FormOverlayView>
); );
}; };
@@ -66,12 +66,18 @@ const StorageVolume = ({
const applyMode = (mode: string) => { const applyMode = (mode: string) => {
if (mode === StorageModeValueMap.Temporary) { if (mode === StorageModeValueMap.Temporary) {
form.setFieldValue(['spec', 'volume'], { form.setFieldValue(
ephemeral: { capacity: `${DEFAULT_TEMP_CAPACITY_GB}Gi` } ['spec', 'volume', 'ephemeral', 'capacity'],
}); form.getFieldValue(['spec', 'volume', 'ephemeral', 'capacity']) ||
DEFAULT_TEMP_CAPACITY_GB
);
return; 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) => { const handleModeChange = (mode: string) => {
@@ -82,10 +88,8 @@ const StorageVolume = ({
const handleCreateStorage = async (values: StorageFormData) => { const handleCreateStorage = async (values: StorageFormData) => {
try { try {
await createStorage({ data: values }); await createStorage({ data: values });
await fetchStorage({ page: 1, perPage: 100 }); await fetchStorage({ page: -1 });
form.setFieldValue(['spec', 'volume'], { form.setFieldValue(['spec', 'volume', 'persistent', 'name'], values.name);
persistent: { name: values.name }
});
setOverlayOpen(false); setOverlayOpen(false);
} catch (error) { } catch (error) {
// ignore // ignore
@@ -53,6 +53,12 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
dataList = [] dataList = []
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const handleSelect = (item: TemplateItem) => {
if (value === item.id) return;
onChange?.(item.id, item);
};
return ( return (
<TemplateGrid> <TemplateGrid>
{dataList.map((item: TemplateItem) => ( {dataList.map((item: TemplateItem) => (
@@ -63,7 +69,7 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
hoverable hoverable
height={102} height={102}
active={value === item.id} active={value === item.id}
onClick={() => onChange?.(item.id, item)} onClick={() => handleSelect(item)}
> >
<TemplateContent> <TemplateContent>
<div className="name"> <div className="name">
@@ -9,19 +9,23 @@ const useViewEvents = () => {
name: string; name: string;
namespace: string; namespace: string;
clusterID?: number; clusterID?: number;
hasPersistentVolume: boolean;
}>({ }>({
open: false, open: false,
name: '', name: '',
namespace: '', namespace: '',
clusterID: undefined clusterID: undefined,
hasPersistentVolume: false
}); });
const openModal = (row?: ListItem) => { const openModal = (row?: ListItem) => {
const volume = row?.spec?.volume;
setOpenModalStatus({ setOpenModalStatus({
open: true, open: true,
name: row?.name || '', name: row?.name || '',
namespace: row?.status?.namespace || '', namespace: row?.status?.namespace || '',
clusterID: row?.clusterId ?? undefined clusterID: row?.clusterId ?? undefined,
hasPersistentVolume: !!volume?.persistent?.name
}); });
}; };
@@ -30,7 +34,8 @@ const useViewEvents = () => {
open: false, open: false,
name: '', name: '',
namespace: '', namespace: '',
clusterID: undefined clusterID: undefined,
hasPersistentVolume: false
}); });
}; };
+2 -1
View File
@@ -80,7 +80,7 @@ const GPUService: React.FC = () => {
if (openInstanceModalStatus.realAction === PageAction.CREATE) { if (openInstanceModalStatus.realAction === PageAction.CREATE) {
await deleteGPUServiceInstance(openInstanceModalStatus.currentData!.id); await deleteGPUServiceInstance(openInstanceModalStatus.currentData!.id);
await new Promise((resolve) => { await new Promise((resolve) => {
setTimeout(resolve, 500); setTimeout(resolve, 300);
}); });
await createInstance({ data }); await createInstance({ data });
} else if (openInstanceModalStatus.action === PageAction.EDIT) { } else if (openInstanceModalStatus.action === PageAction.EDIT) {
@@ -233,6 +233,7 @@ const GPUService: React.FC = () => {
name={openViewEventsModalStatus.name} name={openViewEventsModalStatus.name}
namespace={openViewEventsModalStatus.namespace} namespace={openViewEventsModalStatus.namespace}
clusterID={openViewEventsModalStatus.clusterID} clusterID={openViewEventsModalStatus.clusterID}
hasPersistentVolume={openViewEventsModalStatus.hasPersistentVolume}
onCancel={closeViewEventsModal} onCancel={closeViewEventsModal}
/> />
<DeleteModal ref={modalRef} /> <DeleteModal ref={modalRef} />
@@ -2,7 +2,6 @@ import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import { import {
Input as CInput, Input as CInput,
Select as SealSelect, Select as SealSelect,
Textarea,
useAppUtils useAppUtils
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
@@ -43,13 +42,6 @@ const Basic = ({ action }: { action: string }) => {
label={intl.formatMessage({ id: 'common.table.displayName' })} label={intl.formatMessage({ id: 'common.table.displayName' })}
/> />
</Form.Item> </Form.Item>
<Form.Item<FormData> name="description">
<Textarea
scaleSize={true}
trim={false}
label={intl.formatMessage({ id: 'common.table.description' })}
/>
</Form.Item>
<Form.Item<FormData> <Form.Item<FormData>
name="type" name="type"
rules={[ rules={[
@@ -24,6 +24,9 @@ const NFSForm = ({ action }: { action: string }) => {
> >
<CInput.Input <CInput.Input
required required
description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.server.tips'
})}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.server' id: 'gpuservice.storageType.nfs.server'
})} })}
@@ -40,14 +43,14 @@ const NFSForm = ({ action }: { action: string }) => {
> >
<CInput.Input <CInput.Input
required required
description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.share.tips'
})}
label={intl.formatMessage({ id: 'gpuservice.storageType.nfs.share' })} label={intl.formatMessage({ id: 'gpuservice.storageType.nfs.share' })}
/> />
</Form.Item> </Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'subDirectory']}> <Form.Item<FormData> name={['spec', 'nfs', 'subDirectory']}>
<CInput.Input <CInput.Input
description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.subDirectory.tips'
})}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.subDirectory' id: 'gpuservice.storageType.nfs.subDirectory'
})} })}
@@ -12,8 +12,23 @@ import { FormData } from '../config/types';
const S3Form = ({ action }: { action: string }) => { const S3Form = ({ action }: { action: string }) => {
console.log('action', action); console.log('action', action);
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance<FormData>();
const { getRuleMessage } = useAppUtils(); 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 ( return (
<> <>
<Form.Item<FormData> <Form.Item<FormData>
@@ -39,7 +54,11 @@ const S3Form = ({ action }: { action: string }) => {
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storageType.s3.endpoint' 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> </Form.Item>
<Flex gap={16}> <Flex gap={16}>
@@ -53,11 +72,36 @@ const S3Form = ({ action }: { action: string }) => {
</Form.Item> </Form.Item>
</div> </div>
<div style={{ flex: 1 }}> <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 <CInput.Input
description={intl.formatMessage({ required
id: 'gpuservice.storageType.s3.bucket.tips' 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({ label={intl.formatMessage({
id: 'gpuservice.storageType.s3.bucket' id: 'gpuservice.storageType.s3.bucket'
})} })}
@@ -66,18 +66,18 @@ const useStorageTypeColumns = ({
sorter: false, sorter: false,
render: (_text, record) => getKindLabel(record) render: (_text, record) => getKindLabel(record)
}, },
{ // {
title: intl.formatMessage({ id: 'common.table.description' }), // title: intl.formatMessage({ id: 'common.table.description' }),
dataIndex: 'description', // dataIndex: 'description',
key: 'description', // key: 'description',
sorter: false, // sorter: false,
ellipsis: { showTitle: false }, // ellipsis: { showTitle: false },
render: (text: string) => ( // render: (text: string) => (
<AutoTooltip ghost minWidth={20}> // <AutoTooltip ghost minWidth={20}>
{text || '-'} // {text || '-'}
</AutoTooltip> // </AutoTooltip>
) // )
}, // },
{ {
title: intl.formatMessage({ id: 'common.table.createTime' }), title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at', dataIndex: 'created_at',
@@ -2,6 +2,7 @@ import { PageActionType } from '@/config/types';
import { ModalFooter } from '@gpustack/core-ui'; import { ModalFooter } from '@gpustack/core-ui';
import { useRef } from 'react'; import { useRef } from 'react';
import FormDrawer from '../../../_components/form-drawer'; import FormDrawer from '../../../_components/form-drawer';
import { FormContext } from '../config/form-context';
import { FormData, ListItem } from '../config/types'; import { FormData, ListItem } from '../config/types';
import GPUServiceStorageForm from '../forms'; import GPUServiceStorageForm from '../forms';
@@ -12,6 +13,7 @@ type AddModalProps = {
onOk: (values: FormData) => void; onOk: (values: FormData) => void;
data?: ListItem | null; data?: ListItem | null;
onCancel: () => void; onCancel: () => void;
storageClassList: Global.BaseOption<string>[];
}; };
const AddModal: React.FC<AddModalProps> = ({ const AddModal: React.FC<AddModalProps> = ({
@@ -20,7 +22,8 @@ const AddModal: React.FC<AddModalProps> = ({
open, open,
onOk, onOk,
data, data,
onCancel onCancel,
storageClassList
}) => { }) => {
const form = useRef<any>(null); const form = useRef<any>(null);
@@ -58,13 +61,15 @@ const AddModal: React.FC<AddModalProps> = ({
/> />
} }
> >
<GPUServiceStorageForm <FormContext.Provider value={{ storageClassList }}>
ref={form} <GPUServiceStorageForm
action={action} ref={form}
currentData={data} action={action}
onFinish={onFinish} currentData={data}
open={open} onFinish={onFinish}
/> open={open}
/>
</FormContext.Provider>
</FormDrawer> </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, Input as CInput,
InputNumber, InputNumber,
Select as SealSelect, Select as SealSelect,
Textarea,
useAppUtils useAppUtils
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd'; import { Flex, Form } from 'antd';
import { useEffect } from 'react'; import { useContext } from 'react';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field'; import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormContext } from '../config/form-context';
import { FormData } from '../config/types'; 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 intl = useIntl();
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const { storageClassList, fetchData, loading } = useQueryStorageClass(); const { storageClassList } = useContext(FormContext);
useEffect(() => {
if (open) {
fetchData({ page: 1, perPage: 100 });
}
}, [open]);
return ( return (
<> <>
@@ -52,13 +45,6 @@ const Basic = ({ open, action }: { open: boolean; action: string }) => {
label={intl.formatMessage({ id: 'common.table.displayName' })} label={intl.formatMessage({ id: 'common.table.displayName' })}
/> />
</Form.Item> </Form.Item>
<Form.Item<FormData> name="description">
<Textarea
trim={false}
scaleSize={true}
label={intl.formatMessage({ id: 'common.table.description' })}
/>
</Form.Item>
<Flex gap={16}> <Flex gap={16}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Form.Item<FormData> <Form.Item<FormData>
@@ -74,7 +60,6 @@ const Basic = ({ open, action }: { open: boolean; action: string }) => {
disabled={action === PageAction.EDIT} disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'gpuservice.storage.type' })} label={intl.formatMessage({ id: 'gpuservice.storage.type' })}
required required
loading={loading}
options={storageClassList} options={storageClassList}
/> />
</Form.Item> </Form.Item>
@@ -54,7 +54,7 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
preserve={false} preserve={false}
initialValues={{}} initialValues={{}}
> >
<Basic open={open} action={action} /> <Basic action={action} open={open} />
</Form> </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 { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table'; import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { rowActionList, status, StoragePhaseLabelMap } from '../config'; import { rowActionList } from '../config';
import { ListItem } from '../config/types'; import { ListItem } from '../config/types';
interface ColumnsHookProps { interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void; handleSelect: (val: string, record: ListItem) => void;
storageClassList: Global.BaseOption<string>[];
sortOrder: string[]; sortOrder: string[];
} }
const useStorageColumns = ({ const useStorageColumns = ({
handleSelect, handleSelect,
storageClassList,
sortOrder sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => { }: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl(); const intl = useIntl();
@@ -37,7 +39,14 @@ const useStorageColumns = ({
dataIndex: ['spec', 'type'], dataIndex: ['spec', 'type'],
key: 'type', key: 'type',
sorter: false, 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' }), title: intl.formatMessage({ id: 'gpuservice.storage.capacity' }),
@@ -46,23 +55,23 @@ const useStorageColumns = ({
sorter: false, sorter: false,
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-') render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-')
}, },
{ // {
title: intl.formatMessage({ id: 'common.table.status' }), // title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: ['status', 'phase'], // dataIndex: ['status', 'phase'],
key: 'status', // key: 'status',
sorter: false, // sorter: false,
render: (value: string) => // render: (value: string) =>
value ? ( // value ? (
<StatusTag // <StatusTag
statusValue={{ // statusValue={{
status: status[value], // status: status[value],
text: StoragePhaseLabelMap[value] || value // text: StoragePhaseLabelMap[value] || value
}} // }}
></StatusTag> // ></StatusTag>
) : ( // ) : (
'-' // '-'
) // )
}, // },
{ {
title: intl.formatMessage({ id: 'common.table.createTime' }), title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at', dataIndex: 'created_at',
@@ -89,7 +98,7 @@ const useStorageColumns = ({
) )
} }
]; ];
}, [handleSelect, sortOrder, intl]); }, [handleSelect, sortOrder, storageClassList, intl]);
}; };
export default useStorageColumns; export default useStorageColumns;
+10 -2
View File
@@ -6,6 +6,7 @@ import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, message, Table } from 'antd'; import { ConfigProvider, message, Table } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import { useEffect } from 'react';
import PageBox from '../../_components/page-box'; import PageBox from '../../_components/page-box';
import { import {
deleteGPUServiceStorage, deleteGPUServiceStorage,
@@ -17,6 +18,7 @@ import { FormData, ListItem } from './config/types';
import useCreateStorageModal from './hooks/use-create-storage-modal'; import useCreateStorageModal from './hooks/use-create-storage-modal';
import useStorageColumns from './hooks/use-storage-columns'; import useStorageColumns from './hooks/use-storage-columns';
import useCreateStorage from './services/use-create-storage'; import useCreateStorage from './services/use-create-storage';
import useQueryStorageClass from './services/use-query-storage-class';
import useUpdateStorage from './services/use-update-storage'; import useUpdateStorage from './services/use-update-storage';
const GPUServiceStorage: React.FC = () => { const GPUServiceStorage: React.FC = () => {
@@ -40,16 +42,20 @@ const GPUServiceStorage: React.FC = () => {
fetchAPI: queryGPUServiceStorage, fetchAPI: queryGPUServiceStorage,
deleteAPI: deleteGPUServiceStorage, deleteAPI: deleteGPUServiceStorage,
watch: false, watch: false,
polling: true,
API: GPU_SERVICE_STORAGE_API, API: GPU_SERVICE_STORAGE_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.storage' }) contentForDelete: intl.formatMessage({ id: 'gpuservice.storage' })
}); });
const { storageClassList, fetchData: fetchStorageClass } =
useQueryStorageClass();
const { fetchData: createStorage } = useCreateStorage(); const { fetchData: createStorage } = useCreateStorage();
const { fetchData: updateStorage } = useUpdateStorage(); const { fetchData: updateStorage } = useUpdateStorage();
const { openStorageModalStatus, openStorageModal, closeStorageModal } = const { openStorageModalStatus, openStorageModal, closeStorageModal } =
useCreateStorageModal(); useCreateStorageModal();
useEffect(() => {
fetchStorageClass({ page: -1 });
}, []);
const handleAddStorage = () => { const handleAddStorage = () => {
openStorageModal( openStorageModal(
PageAction.CREATE, PageAction.CREATE,
@@ -123,6 +129,7 @@ const GPUServiceStorage: React.FC = () => {
const columns = useStorageColumns({ const columns = useStorageColumns({
handleSelect, handleSelect,
storageClassList,
sortOrder sortOrder
}); });
@@ -170,6 +177,7 @@ const GPUServiceStorage: React.FC = () => {
action={openStorageModalStatus.action} action={openStorageModalStatus.action}
title={openStorageModalStatus.title} title={openStorageModalStatus.title}
data={openStorageModalStatus.currentData} data={openStorageModalStatus.currentData}
storageClassList={storageClassList}
onCancel={closeStorageModal} onCancel={closeStorageModal}
onOk={handleModalOk} onOk={handleModalOk}
/> />
@@ -29,9 +29,9 @@ const protocolOptions = [
]; ];
const wellKnownTcpPortNames: Record<number, string> = { const wellKnownTcpPortNames: Record<number, string> = {
22: 'ssh', 22: 'SSH',
80: 'http', 80: 'WEB',
443: 'https' 443: 'HTTPS'
}; };
const getAutoFilledName = ( const getAutoFilledName = (