fix: storage api alignment

This commit is contained in:
jialin
2026-05-11 19:49:21 +08:00
committed by jialin
parent 7fa44644fd
commit 7d0cfeff77
34 changed files with 881 additions and 324 deletions
+13 -3
View File
@@ -192,9 +192,9 @@ const baseRoutes = [
name: 'instances',
path: '/gpu-service/instances',
key: 'gpuServiceList',
icon: 'icon-instances-outlined',
selectedIcon: 'icon-instances-filled',
defaultIcon: 'icon-instances-outlined',
icon: 'icon-cloud-outlined',
selectedIcon: 'icon-cloud-filled',
defaultIcon: 'icon-cloud-outlined',
access: 'canSeeAdmin',
component: './gpu-service/instances'
},
@@ -217,6 +217,16 @@ const baseRoutes = [
defaultIcon: 'icon-storage-outlined',
access: 'canSeeAdmin',
component: './gpu-service/storage'
},
{
name: 'publicKeys',
path: '/gpu-service/public-keys',
key: 'gpuServicePublicKeys',
icon: 'icon-ssh-outlined',
selectedIcon: 'icon-ssh-filled',
defaultIcon: 'icon-ssh-outlined',
access: 'canSeeAdmin',
component: './gpu-service/public-keys'
}
]
},
+38
View File
@@ -16,6 +16,43 @@ declare namespace Global {
};
}
interface K8sPageResponse<T> {
apiVersion: string;
items: T[];
kind: string;
metadata: {
continue?: string;
resourceVersion: string;
selfLink?: string;
[key: string]: any;
};
}
interface K8sCommonData {
kind: string;
apiVersion: string;
metadata: {
name: string;
namespace: string;
[key: string]: any;
};
[key: string]: any;
}
interface K8sSearchParams {
allowWatchBookmarks?: boolean;
continue?: string;
fieldSelector?: string;
labelSelector?: string;
limit?: number;
resourceVersion?: string;
resourceVersionMatch?: string;
sendInitialEvents?: boolean;
timeoutSeconds?: number;
watch?: boolean;
[key: string]: any;
}
interface UserInfo {
username: string;
is_admin: boolean;
@@ -24,6 +61,7 @@ declare namespace Global {
id: number;
source: string;
avatar_url: string;
org_name?: string;
}
type EmptyObject = Record<never, never>;
+13
View File
@@ -72,3 +72,16 @@ export const lengthReg = /^.{6,64}$/;
*/
export const modelNameReg =
/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,61}[A-Za-z0-9])?$/g;
/**
* Label name rules:
* 1. no more than 63 characters
* 2. contain only lowercase alphanumeric characters and '-'
* 3. start and end with an alphanumeric character
* 4. must not contain consecutive '-' characters
* 5. must not start with a digit
* 6. must not end with a '-'
*/
export const validateLabelNameRegxFor63 =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
+1 -1
View File
@@ -356,7 +356,7 @@ export default (props: any) => {
config={{
apiBaseUrl: GPUSTACK_API_BASE_URL,
theme: userSettings.theme,
iconUrl: '//at.alicdn.com/t/c/font_4613488_tzcsatubq4f.js',
iconUrl: '//at.alicdn.com/t/c/font_4613488_ieeghfm8o3o.js',
isDarkTheme: userSettings.isDarkTheme,
defaultColorPrimary: COLOR_PRIMARY
}}
+2 -1
View File
@@ -44,5 +44,6 @@ export default {
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage'
'menu.gpuService.storage': 'Storage',
'menu.gpuService.publicKeys': 'SSH Public Keys'
};
+2 -1
View File
@@ -44,7 +44,8 @@ export default {
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage'
'menu.gpuService.storage': 'Storage',
'menu.gpuService.publicKeys': 'SSH Public Keys'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+2 -1
View File
@@ -44,7 +44,8 @@ export default {
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage'
'menu.gpuService.storage': 'Storage',
'menu.gpuService.publicKeys': 'SSH Public Keys'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+2 -1
View File
@@ -44,7 +44,8 @@ export default {
'menu.gpuService': 'GPU Service',
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage'
'menu.gpuService.storage': 'Storage',
'menu.gpuService.publicKeys': 'SSH Public Keys'
};
// ========== To-Do: Translate Keys (Remove After Translation) ==========
+2 -1
View File
@@ -44,5 +44,6 @@ export default {
'menu.gpuService': 'GPU 服务',
'menu.gpuService.instances': 'GPU 实例',
'menu.gpuService.templates': '实例模板',
'menu.gpuService.storage': '存储'
'menu.gpuService.storage': '存储',
'menu.gpuService.publicKeys': 'SSH 公钥'
};
+8
View File
@@ -0,0 +1,8 @@
export const apiVersion = 'worker.gpustack.ai/v1';
export const KindMapping = {
sshPublicKey: 'InstanceSSHPublicKey',
instance: 'Instance',
instancePersistentVolume: 'InstancePersistentVolume',
instanceTemplate: 'InstanceTemplate'
};
+40 -4
View File
@@ -1,11 +1,15 @@
import { request } from '@umijs/max';
import { mockInstanceData } from '../config/mock-data';
import { FormData, ListItem } from '../config/types';
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
export const GPU_SERVICE_INSTANCES_API = '/gpu-service-instances';
export const GPU_SERVICE_INSTANCES_API = (namespace: string) =>
`/proxy/apis/worker.gpustack.ai/v1/namespaces/${namespace}/instances`;
export const GPU_SERVICE_INSTANCES_TYPE_API =
'/proxy/apis/worker.gpustack.ai/v1/instancetypes';
export async function queryGPUServiceInstances(
params: Global.SearchParams,
params: Global.K8sSearchParams,
options?: any
) {
// return request<Global.PageResponse<ListItem>>(GPU_SERVICE_INSTANCES_API, {
@@ -35,7 +39,7 @@ export async function queryGPUServiceInstances(
page,
perPage
}
} as Global.PageResponse<ListItem>;
} as Global.K8sPageResponse<ListItem>;
}
export async function createGPUServiceInstance(params: { data: FormData }) {
@@ -62,3 +66,35 @@ export async function deleteGPUServiceInstance(id: number) {
method: 'DELETE'
});
}
// =========== Instance Types ===========
export async function queryGPUServiceInstanceTypes(
params: Global.K8sSearchParams,
options?: any
) {
return request<Global.K8sPageResponse<InstanceTypeItem>>(
GPU_SERVICE_INSTANCES_TYPE_API,
{
method: 'GET',
params,
cancelToken: options?.token
}
);
}
export async function queryGPUServiceInstanceTypeItems(
params: {
name: string;
},
options?: any
) {
return request<InstanceTypeItem>(
`${GPU_SERVICE_INSTANCES_TYPE_API}/${params.name}`,
{
method: 'GET',
params,
cancelToken: options?.token
}
);
}
+87 -14
View File
@@ -1,23 +1,57 @@
export interface FormData {
export type ImagePullPolicy = 'Always' | 'IfNotPresent' | 'Never';
export interface InstanceEnvVar {
name: string;
description?: string;
image?: string;
instance_type?: string;
instance_type_id?: number;
template_id?: number;
gpu_count?: number;
replicas?: number;
storage_mode?: string;
storage_id?: number;
local_storage_size_gb?: number;
cluster_id?: number;
mount_path?: string;
value?: string;
}
export interface InstancePort {
port: number;
protocol?: string;
}
export interface InstanceResources {
cpu?: string;
ram?: string;
localStorage?: string;
accelerator?: string;
}
export interface InstanceVolume {
ephemeral?: {
capacity?: string;
};
persistent?: {
name: string;
};
}
// instance form data
export interface FormData {
metadata: {
name: string;
namespace: string;
};
spec: {
image: string;
displayName: string;
command: string[];
ports: InstancePort[];
env: InstanceEnvVar[];
volumeMount: string;
resources: InstanceResources;
description: string;
volume: InstanceVolume;
sshPublicKey: {
name: string;
};
};
}
// instance list item
export interface ListItem extends FormData {
id: number;
status?: string;
endpoint?: string;
created_at?: string;
updated_at?: string;
}
@@ -31,3 +65,42 @@ export interface InstanceItem {
gpu_count: number;
status: string; // available, unavailable
}
export interface InstanceTypeSpec {
acceleratable: boolean;
computeCapability?: string;
family?: string;
group: string;
manufacturer?: string;
memory?: string;
product?: string;
sliced?: number;
}
export interface InstanceTypeResource {
capacity?: string;
onceMaxRequest?: string;
remaining?: string;
}
export interface InstanceTypeStatus {
accelerator: InstanceTypeResource;
cpu: InstanceTypeResource;
localStorage: InstanceTypeResource;
phase: string;
phaseMessage?: string;
ram: InstanceTypeResource;
}
export interface InstanceTypeItem {
apiVersion: string;
kind: string;
metadata: {
name: string;
namespace: string;
};
id: number;
name: string;
spec: InstanceTypeSpec;
status: InstanceTypeStatus;
}
@@ -1,6 +1,8 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import {
CheckboxField,
Input as CInput,
CollapsePanel,
IconFont,
ScrollSpyTabs,
@@ -206,6 +208,20 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}
]}
/>
<Form.Item<FormData>
name="enable_ssh"
valuePropName="checked"
style={{ marginBottom: 8 }}
>
<CheckboxField label={'SSH Terminal Access'}></CheckboxField>
</Form.Item>
<Form.Item<FormData>
data-field="name"
hidden
name={['spec', 'sshPublicKey', 'name']}
>
<CInput.Input />
</Form.Item>
</Form>
</ScrollSpyTabs>
);
@@ -1,7 +1,6 @@
import { InputNumber as CInputNumber, Input, Select } from '@gpustack/core-ui';
import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui';
import { Button, Flex, Form, Radio } from 'antd';
import styled from 'styled-components';
import { StorageStatusValueMap } from '../../storage/config';
import { mockStorageData } from '../../storage/config/mock-data';
import { StorageModeValueMap } from '../config';
import { FormData } from '../config/types';
@@ -57,29 +56,24 @@ const StorageVolume = () => {
</Flex>
{storageMode === StorageModeValueMap.Existing && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="storage_id"
rules={[
{
required: true,
message: '请选择预存储卷'
}
]}
>
<Select
label="持久卷"
required
options={mockStorageData.map((item) => ({
label: `${item.name} / ${item.capacity_gb ?? '-'} GB`,
value: item.id,
disabled: item.status !== StorageStatusValueMap.Available
}))}
/>
</Form.Item>
</div>
</div>
<Form.Item<FormData>
name="storage_id"
rules={[
{
required: true,
message: '请选择预存储卷'
}
]}
>
<Select
label="持久卷"
required
options={mockStorageData.map((item) => ({
label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`,
value: item.id
}))}
/>
</Form.Item>
)}
{storageMode === StorageModeValueMap.Temporary && (
@@ -95,22 +89,6 @@ const StorageVolume = () => {
<CInputNumber min={1} precision={0} label="存储容量 (GB)" required />
</Form.Item>
)}
{/* 挂载路径 */}
<Form.Item<FormData>
name="mount_path"
rules={[
{
required: true,
message: '请输入存储挂载路径'
}
]}
>
<Input.Input
label="挂载路径"
required
placeholder="挂载路径须以 / 开头"
/>
</Form.Item>
</FieldBlock>
);
};
@@ -0,0 +1,55 @@
import { request } from '@umijs/max';
import { FormData, ListItem } from '../types';
export const GPU_SERVICE_PUBLIC_KEY_API = (namespace: string) =>
`/proxy/apis/worker.gpustack.ai/v1/namespaces/${namespace}/instancesshpublickeys`;
export async function queryGPUServicePublicKeys(
params: Global.K8sSearchParams,
options?: any
) {
return request<Global.K8sPageResponse<ListItem>>(
GPU_SERVICE_PUBLIC_KEY_API(params.namespace || ''),
{
method: 'GET',
params,
cancelToken: options?.token
}
);
}
export async function createGPUServicePublicKey(params: {
namespace: string;
data: FormData;
}) {
return request<ListItem>(GPU_SERVICE_PUBLIC_KEY_API(params.namespace), {
method: 'POST',
data: params.data
});
}
export async function updateGPUServicePublicKey(params: {
namespace: string;
id: number;
data: FormData;
}) {
return request<ListItem>(
`${GPU_SERVICE_PUBLIC_KEY_API(params.namespace)}/${params.id}`,
{
method: 'PUT',
data: params.data
}
);
}
export async function deleteGPUServicePublicKey(params: {
namespace: string;
id: number;
}) {
return request(
`${GPU_SERVICE_PUBLIC_KEY_API(params.namespace)}/${params.id}`,
{
method: 'DELETE'
}
);
}
@@ -0,0 +1,63 @@
import { INPUT_WIDTH } from '@/constants';
import { Textarea } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Form, message } from 'antd';
import React from 'react';
import PageBox from '../../_components/page-box';
interface PublicKeyFormData {
public_key?: string;
}
const PLACEHOLDER = `以 ssh-rsa 或 ssh-ed25519 开头,每个 Public Key 单独一行
查看 Public Key
- RSA
cat ~/.ssh/id_rsa.pub
- Ed25519
cat ~/.ssh/id_ed25519.pub`;
const GPUServicePublicKeys: React.FC = () => {
const intl = useIntl();
const [form] = Form.useForm<PublicKeyFormData>();
const handleSave = async () => {
try {
await form.validateFields();
message.success('操作成功');
} catch {
// ignore
}
};
return (
<PageBox>
<Form
style={{ width: INPUT_WIDTH.large }}
name="gpuServicePublicKeyForm"
form={form}
>
<Form.Item<PublicKeyFormData> name="public_key">
<Textarea
required
label="SSH Public Key"
placeholder={PLACEHOLDER}
trim={false}
alwaysFocus
autoSize={{ minRows: 8, maxRows: 16 }}
style={{ width: INPUT_WIDTH.large }}
/>
</Form.Item>
<Button
type="primary"
style={{ width: 120, marginTop: 24 }}
onClick={handleSave}
>
{intl.formatMessage({ id: 'common.button.save' })}
</Button>
</Form>
</PageBox>
);
};
export default GPUServicePublicKeys;
@@ -0,0 +1,48 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { createGPUServicePublicKey } from '../apis';
import { ListItem } from '../types';
interface CreateSshkeyParams {
name: string;
data: string;
}
export default function useCreateSshkey() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: CreateSshkeyParams) =>
createGPUServicePublicKey({
namespace,
data: {
apiVersion,
kind: KindMapping.sshPublicKey,
metadata: {
name: params.name,
namespace
},
data: params.data
}
}),
[namespace]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
CreateSshkeyParams
>({
fetchDetail,
key: 'createSshkey'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,31 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServicePublicKeys } from '../apis';
import { ListItem } from '../types';
export default function useGetSshkey() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: Global.K8sSearchParams = {}, options?: any) =>
queryGPUServicePublicKeys({ ...params, namespace }, options),
[namespace]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.K8sPageResponse<ListItem>,
Global.K8sSearchParams
>({
fetchDetail,
key: 'sshkey'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,50 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { updateGPUServicePublicKey } from '../apis';
import { ListItem } from '../types';
interface UpdateSshkeyParams {
id: number;
name: string;
data: string;
}
export default function useUpdateSshkey() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: UpdateSshkeyParams) =>
updateGPUServicePublicKey({
namespace,
id: params.id,
data: {
apiVersion,
kind: KindMapping.sshPublicKey,
metadata: {
name: params.name,
namespace
},
data: params.data
}
}),
[namespace]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
UpdateSshkeyParams
>({
fetchDetail,
key: 'updateSshkey'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,9 @@
export interface FormData extends Global.K8sCommonData {
data: string;
}
export interface ListItem extends FormData {
id: number;
created_at?: string;
updated_at?: string;
}
+44 -48
View File
@@ -1,64 +1,60 @@
import { request } from '@umijs/max';
import { mockStorageData } from '../config/mock-data';
import { FormData, ListItem } from '../config/types';
import { ListItem } from '../config/types';
export const GPU_SERVICE_STORAGE_API = '/gpu-service-storage';
export const GPU_SERVICE_STORAGE_API = (namespace: string) =>
`/proxy/apis/worker.gpustack.ai/v1/namespaces/${namespace}/instancepersistentvolumes`;
export async function queryGPUServiceStorage(
params: Global.SearchParams,
params: Global.K8sSearchParams,
options?: any
) {
// return request<Global.PageResponse<ListItem>>(GPU_SERVICE_STORAGE_API, {
// method: 'GET',
// params,
// cancelToken: options?.token
// });
const page = params.page || 1;
const perPage = params.perPage || 10;
const search = params.search?.toLowerCase();
const clusterId = params.cluster_id;
const filteredData = mockStorageData.filter((item) => {
const matchSearch = search
? item.name.toLowerCase().includes(search)
: true;
const matchCluster = clusterId ? item.cluster_id === clusterId : true;
return matchSearch && matchCluster;
});
const start = (page - 1) * perPage;
const items = filteredData.slice(start, start + perPage);
return {
items,
pagination: {
total: filteredData.length,
totalPage: Math.ceil(filteredData.length / perPage),
page,
perPage
return request<Global.K8sPageResponse<ListItem>>(
GPU_SERVICE_STORAGE_API(params.namespace || ''),
{
method: 'GET',
params,
cancelToken: options?.token
}
} as Global.PageResponse<ListItem>;
);
}
export async function createGPUServiceStorage(params: { data: FormData }) {
// return request<ListItem>(GPU_SERVICE_STORAGE_API, {
// method: 'POST',
// data: params.data
// });
return true;
export async function createGPUServiceStorage(
params: {
namespace: string;
data: Global.K8sCommonData;
},
option?: any
) {
return request<ListItem>(GPU_SERVICE_STORAGE_API(params.namespace), {
method: 'POST',
data: params.data,
cancelToken: option?.token
});
}
export async function updateGPUServiceStorage(params: {
export async function updateGPUServiceStorage(
params: {
namespace: string;
id: number;
data: Global.K8sCommonData;
},
option?: any
) {
return request<ListItem>(
`${GPU_SERVICE_STORAGE_API(params.namespace)}/${params.id}`,
{
method: 'PUT',
data: params.data,
cancelToken: option?.token
}
);
}
export async function deleteGPUServiceStorage(params: {
namespace: string;
id: number;
data: FormData;
}) {
// return request<ListItem>(`${GPU_SERVICE_STORAGE_API}/${params.id}`, {
// method: 'PUT',
// data: params.data
// });
return true;
}
export async function deleteGPUServiceStorage(id: number) {
return request(`${GPU_SERVICE_STORAGE_API}/${id}`, {
return request(`${GPU_SERVICE_STORAGE_API(params.namespace)}/${params.id}`, {
method: 'DELETE'
});
}
+51 -33
View File
@@ -1,5 +1,3 @@
import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui';
export const StorageTypeValueMap = {
@@ -9,52 +7,72 @@ export const StorageTypeValueMap = {
};
export const StorageTypeLabelMap: Record<string, string> = {
[StorageTypeValueMap.Local]: '默认存储',
[StorageTypeValueMap.Shared]: '默认存储',
[StorageTypeValueMap.Local]: '本地存储',
[StorageTypeValueMap.Shared]: '共享存储',
[StorageTypeValueMap.Object]: '对象存储'
};
export const StorageAccessModeValueMap = {
ReadWriteOnce: 'ReadWriteOnce',
ReadOnlyMany: 'ReadOnlyMany',
ReadWriteMany: 'ReadWriteMany'
};
export const StorageAccessModeOptions = [
export const StorageTypeOptions = [
{
label: 'ReadWriteOnce',
value: StorageAccessModeValueMap.ReadWriteOnce
label: StorageTypeLabelMap[StorageTypeValueMap.Local],
value: StorageTypeValueMap.Local
},
{
label: 'ReadWriteMany',
value: StorageAccessModeValueMap.ReadWriteMany
label: StorageTypeLabelMap[StorageTypeValueMap.Shared],
value: StorageTypeValueMap.Shared
},
{
label: 'ReadOnlyMany',
value: StorageAccessModeValueMap.ReadOnlyMany
label: StorageTypeLabelMap[StorageTypeValueMap.Object],
value: StorageTypeValueMap.Object
}
];
export const StorageStatusValueMap = {
Available: 'Available',
Bound: 'Bound',
Released: 'Released',
Failed: 'Failed'
export const AccessModeValueMap = {
ReadWriteOnce: 'ReadWriteOnce',
ReadOnlyMany: 'ReadOnlyMany',
ReadWriteMany: 'ReadWriteMany',
ReadWriteOncePod: 'ReadWriteOncePod'
};
export const StorageStatusLabelMap: Record<string, string> = {
[StorageStatusValueMap.Available]: 'Available',
[StorageStatusValueMap.Bound]: 'Bound',
[StorageStatusValueMap.Released]: 'Released',
[StorageStatusValueMap.Failed]: 'Failed'
export const AccessModeOptions = [
{
label: 'ReadWriteOnce',
value: AccessModeValueMap.ReadWriteOnce
},
{
label: 'ReadWriteMany',
value: AccessModeValueMap.ReadWriteMany
},
{
label: 'ReadOnlyMany',
value: AccessModeValueMap.ReadOnlyMany
},
{
label: 'ReadWriteOncePod',
value: AccessModeValueMap.ReadWriteOncePod
}
];
export const ReClaimPolicyValueMap = {
Retain: 'Retain',
Delete: 'Delete',
Recycle: 'Recycle'
};
export const status: Record<string, StatusType> = {
[StorageStatusValueMap.Available]: StatusMaps.success,
[StorageStatusValueMap.Bound]: StatusMaps.transitioning,
[StorageStatusValueMap.Released]: StatusMaps.inactive,
[StorageStatusValueMap.Failed]: StatusMaps.error
};
export const ReClaimPolicyOptions = [
{
label: 'Retain',
value: ReClaimPolicyValueMap.Retain
},
{
label: 'Delete',
value: ReClaimPolicyValueMap.Delete
},
{
label: 'Recycle',
value: ReClaimPolicyValueMap.Recycle
}
];
export const rowActionList = [
{
@@ -1,68 +1,64 @@
import { StorageStatusValueMap, StorageTypeValueMap } from '.';
import { StorageTypeValueMap } from '.';
import { ListItem } from './types';
export const mockStorageData: ListItem[] = [
export const mockStorageData: (ListItem & { cluster_id: number })[] = [
{
id: 1,
name: 'local-nvme-cache',
type: StorageTypeValueMap.Local,
capacity_gb: 500,
mount_path: '/mnt/nvme',
access_modes: ['ReadWriteOnce'],
parameters: {
path: '/mnt/nvme'
},
status: StorageStatusValueMap.Available,
cluster_id: 1,
description: 'Local NVMe cache for hot model files',
metadata: {
name: 'local-nvme-cache',
namespace: 'default'
},
spec: {
accessMode: 'ReadWriteOnce',
capacity: '500Gi',
type: StorageTypeValueMap.Local
},
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-10T10:00:00Z'
},
{
id: 2,
name: 'shared-model-store',
type: StorageTypeValueMap.Shared,
capacity_gb: 2048,
mount_path: '/models',
access_modes: ['ReadWriteMany'],
parameters: {
storageClassName: 'nfs-client'
},
status: StorageStatusValueMap.Bound,
cluster_id: 1,
description: 'Shared storage for model weights',
metadata: {
name: 'shared-model-store',
namespace: 'default'
},
spec: {
accessMode: 'ReadWriteMany',
capacity: '2Ti',
type: StorageTypeValueMap.Shared
},
created_at: '2026-04-02T10:00:00Z',
updated_at: '2026-04-11T10:00:00Z'
},
{
id: 3,
name: 'object-dataset-bucket',
type: StorageTypeValueMap.Object,
capacity_gb: 10240,
mount_path: '/datasets',
access_modes: ['ReadOnlyMany'],
parameters: {
bucket: 'datasets'
},
status: StorageStatusValueMap.Released,
cluster_id: 2,
description: 'Object storage mounted for datasets',
metadata: {
name: 'object-dataset-bucket',
namespace: 'default'
},
spec: {
accessMode: 'ReadOnlyMany',
capacity: '10Ti',
type: StorageTypeValueMap.Object
},
created_at: '2026-04-03T10:00:00Z',
updated_at: '2026-04-12T10:00:00Z'
},
{
id: 4,
name: 'failed-shared-cache',
type: StorageTypeValueMap.Shared,
capacity_gb: 1024,
mount_path: '/failed-cache',
access_modes: ['ReadWriteMany'],
parameters: {
storageClassName: 'nfs-client'
},
status: StorageStatusValueMap.Failed,
cluster_id: 2,
description: 'Shared cache waiting for storage backend recovery',
metadata: {
name: 'failed-shared-cache',
namespace: 'default'
},
spec: {
accessMode: 'ReadWriteMany',
capacity: '1Ti',
type: StorageTypeValueMap.Shared
},
created_at: '2026-04-04T10:00:00Z',
updated_at: '2026-04-13T10:00:00Z'
}
+16 -9
View File
@@ -1,13 +1,20 @@
export type AccessModeType =
| 'ReadOnlyMany'
| 'ReadWriteMany'
| 'ReadWriteOnce'
| 'ReadWriteOncePod';
export type ReClaimPolicyType = 'Retain' | 'Delete' | 'Recycle';
export interface FormData {
name: string;
description?: string;
type?: string;
capacity_gb?: number;
mount_path?: string;
access_modes?: string[];
parameters?: Record<string, any>;
status?: string;
cluster_id?: number;
metadata: {
name: string;
namespace: string;
};
spec: {
accessMode: AccessModeType;
capacity: string;
type: string;
};
}
export interface ListItem extends FormData {
+22 -32
View File
@@ -1,24 +1,13 @@
import {
Input as CInput,
InputNumber as CInputNumber,
Select as SealSelect
} from '@gpustack/core-ui';
import { Input as CInput, Select as SealSelect } from '@gpustack/core-ui';
import { Form } from 'antd';
import { StorageAccessModeOptions } from '../config';
import { AccessModeOptions, StorageTypeOptions } from '../config';
import { FormData } from '../config/types';
const Basic = () => {
const form = Form.useFormInstance<FormData>();
const parameters = Form.useWatch('parameters', form);
const handleParametersChange = (value: Record<string, any>) => {
form.setFieldValue('parameters', value);
};
return (
<>
<Form.Item<FormData>
name="name"
name={['metadata', 'name']}
rules={[
{
required: true,
@@ -31,7 +20,7 @@ const Basic = () => {
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="type"
name={['spec', 'type']}
rules={[
{
required: true,
@@ -39,12 +28,16 @@ const Basic = () => {
}
]}
>
<SealSelect label="存储类型" required options={[]}></SealSelect>
<SealSelect
label="存储类型"
required
options={StorageTypeOptions}
></SealSelect>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
name="capacity_gb"
name={['spec', 'capacity']}
rules={[
{
required: true,
@@ -52,28 +45,25 @@ const Basic = () => {
}
]}
>
<CInputNumber min={1} precision={0} label="容量 (GB)" required />
<CInput.Input label="容量" required placeholder="例如:10Gi" />
</Form.Item>
</div>
</div>
<Form.Item<FormData> name="access_modes">
<Form.Item<FormData>
name={['spec', 'accessMode']}
rules={[
{
required: true,
message: '请选择访问模式'
}
]}
>
<SealSelect
label="存储卷访问方式"
allowClear
options={StorageAccessModeOptions}
required
options={AccessModeOptions}
></SealSelect>
</Form.Item>
{/* <Form.Item<FormData> name="parameters">
<LabelSelector
label="存储卷配置参数"
labels={parameters || {}}
btnText="添加参数"
onChange={handleParametersChange}
/>
</Form.Item> */}
<Form.Item<FormData> name="description">
<CInput.TextArea label="描述" scaleSize />
</Form.Item>
</>
);
};
+27 -14
View File
@@ -2,7 +2,7 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { StorageStatusValueMap } from '../config';
import { AccessModeValueMap, StorageTypeValueMap } from '../config';
import { FormData, ListItem } from '../config/types';
import Basic from './basic';
@@ -11,12 +11,19 @@ interface StorageFormProps {
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
namespace?: string;
onFinish: (values: FormData) => Promise<void>;
}
const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
(props, ref) => {
const { action, currentData, open, onFinish } = props;
const {
action,
currentData,
open,
namespace = 'default',
onFinish
} = props;
const [form] = Form.useForm<FormData>();
useEffect(() => {
@@ -27,24 +34,30 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
if (action === PageAction.EDIT && currentData) {
form.setFieldsValue({
name: currentData.name,
description: currentData.description,
type: currentData.type,
capacity_gb: currentData.capacity_gb,
mount_path: currentData.mount_path,
access_modes: currentData.access_modes,
parameters: currentData.parameters,
status: currentData.status
metadata: {
name: currentData.metadata?.name,
namespace: currentData.metadata?.namespace
},
spec: {
accessMode: currentData.spec?.accessMode,
capacity: currentData.spec?.capacity,
type: currentData.spec?.type
}
});
return;
}
form.setFieldsValue({
type: '默认存储',
parameters: {},
status: StorageStatusValueMap.Available
metadata: {
namespace
},
spec: {
accessMode:
AccessModeValueMap.ReadWriteOnce as FormData['spec']['accessMode'],
type: StorageTypeValueMap.Local
}
});
}, [action, currentData, form, open]);
}, [action, currentData, form, open, namespace]);
useImperativeHandle(ref, () => ({
submit: () => {
@@ -1,15 +1,9 @@
import { tableSorter } from '@/config/settings';
import { AutoTooltip, DropdownButtons, StatusTag } from '@gpustack/core-ui';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import {
rowActionList,
status,
StorageStatusLabelMap,
StorageStatusValueMap,
StorageTypeLabelMap
} from '../config';
import { rowActionList, StorageTypeLabelMap } from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
@@ -25,7 +19,7 @@ const useStorageColumns = ({
return [
{
title: '名称',
dataIndex: 'name',
dataIndex: ['metadata', 'name'],
key: 'name',
sorter: tableSorter(1),
ellipsis: {
@@ -39,48 +33,25 @@ const useStorageColumns = ({
},
{
title: '类型',
dataIndex: 'type',
dataIndex: ['spec', 'type'],
key: 'type',
sorter: tableSorter(2),
render: (value: string) => StorageTypeLabelMap[value] || value || '-'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
title: '容量',
dataIndex: ['spec', 'capacity'],
key: 'capacity',
sorter: tableSorter(3),
render: (statusValue: string) => {
const value = statusValue || StorageStatusValueMap.Available;
return (
<StatusTag
statusValue={{
status: status[value],
text: StorageStatusLabelMap[value] || value
}}
/>
);
}
render: (value: string) => value ?? '-'
},
{
title: '容量 (GB)',
dataIndex: 'capacity_gb',
key: 'capacity_gb',
title: '访问模式',
dataIndex: ['spec', 'accessMode'],
key: 'accessMode',
sorter: tableSorter(4),
render: (value: number) => value ?? '-'
render: (value: string) => value || '-'
},
// {
// title: '挂载路径',
// dataIndex: 'mount_path',
// key: 'mount_path',
// ellipsis: {
// showTitle: false
// },
// render: (text: string) => (
// <AutoTooltip ghost minWidth={20}>
// {text || '-'}
// </AutoTooltip>
// )
// },
{
title: '创建时间',
dataIndex: 'created_at',
+47 -10
View File
@@ -3,6 +3,7 @@ import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import type { PageActionType } from '@/config/types';
import useTableFetch from '@/hooks/use-table-fetch';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import { useModel } from '@@/plugin-model';
import {
BaseSelect,
DeleteModal,
@@ -14,20 +15,53 @@ import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, Divider, Flex, message, Table } from 'antd';
import _ from 'lodash';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { PageContainerInner } from '../../_components/page-box';
import {
createGPUServiceStorage,
deleteGPUServiceStorage,
GPU_SERVICE_STORAGE_API,
queryGPUServiceStorage,
updateGPUServiceStorage
queryGPUServiceStorage
} from './apis';
import AddModal from './components/add-modal';
import { FormData, ListItem } from './config/types';
import useStorageColumns from './hooks/use-storage-columns';
import useCreateStorage from './services/use-create-storage';
import useUpdateStorage from './services/use-update-storage';
const GPUServiceStorage: React.FC = () => {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const deleteStorage = useCallback(
(id: number) => deleteGPUServiceStorage({ namespace, id }),
[namespace]
);
const fetchStorage = useCallback(
async (
params: any,
options?: any
): Promise<Global.PageResponse<ListItem>> => {
const res = await queryGPUServiceStorage(
{ ...params, namespace },
options
);
const total = res.items?.length ?? 0;
const perPage = params.perPage || 10;
return {
items: res.items ?? [],
pagination: {
total,
totalPage: Math.ceil(total / perPage),
page: params.page || 1,
perPage
}
};
},
[namespace]
);
const {
dataSource,
rowSelection,
@@ -44,12 +78,15 @@ const GPUServiceStorage: React.FC = () => {
handleNameChange
} = useTableFetch<ListItem>({
key: PaginationKey.Storage,
fetchAPI: queryGPUServiceStorage,
deleteAPI: deleteGPUServiceStorage,
fetchAPI: fetchStorage,
deleteAPI: deleteStorage,
watch: false,
API: GPU_SERVICE_STORAGE_API,
API: GPU_SERVICE_STORAGE_API(namespace),
contentForDelete: '存储'
});
const { fetchData: createStorage } = useCreateStorage();
const { fetchData: updateStorage } = useUpdateStorage();
const {
fetchClusterList,
cancelRequest: cancelClusterRequest,
@@ -113,12 +150,12 @@ const GPUServiceStorage: React.FC = () => {
const handleModalOk = async (data: FormData) => {
try {
if (openAddModalStatus.action === PageAction.EDIT) {
await updateGPUServiceStorage({
await updateStorage({
id: openAddModalStatus.currentData!.id,
data
});
} else {
await createGPUServiceStorage({ data });
await createStorage({ data });
}
fetchData();
@@ -133,7 +170,7 @@ const GPUServiceStorage: React.FC = () => {
if (val === 'edit') {
handleEditStorage(row);
} else if (val === 'delete') {
handleDelete({ ...row, name: row.name });
handleDelete({ ...row, name: row.metadata?.name });
}
});
@@ -0,0 +1,50 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { createGPUServiceStorage } from '../apis';
import { FormData, ListItem } from '../config/types';
interface CreateStorageParams {
data: FormData;
}
export default function useCreateStorage() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: CreateStorageParams, option?: any) =>
createGPUServiceStorage(
{
namespace,
data: {
apiVersion,
kind: KindMapping.instancePersistentVolume,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
CreateStorageParams
>({
fetchDetail,
key: 'createStorage'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,52 @@
import { useModel } from '@@/plugin-model';
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { updateGPUServiceStorage } from '../apis';
import { FormData, ListItem } from '../config/types';
interface UpdateStorageParams {
id: number;
data: FormData;
}
export default function useUpdateStorage() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const fetchDetail = useCallback(
(params: UpdateStorageParams, option?: any) =>
updateGPUServiceStorage(
{
namespace,
id: params.id,
data: {
apiVersion,
kind: KindMapping.instancePersistentVolume,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
UpdateStorageParams
>({
fetchDetail,
key: 'updateStorage'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
+20 -14
View File
@@ -1,26 +1,32 @@
export interface PortItem {
protocol: 'udp' | 'tcp';
value?: number;
port: number;
}
export interface EnvItem {
name: string;
value: string;
}
export interface FormData {
name: string;
image?: string;
vendor?: string;
image_pull_policy?: string;
run_command?: string;
boot_disk_size_gb?: number;
volume_size_gb?: number;
volume_mount_path?: string;
ports?: PortItem[];
env?: Record<string, any>;
image: string;
imagePullPolicy: string;
imagePullSecret: {
name: string;
};
command: string[];
privileged: boolean;
ports: PortItem[];
env: EnvItem[];
volumeMount: string;
resources: {
cpu: string;
ram: string;
};
}
export interface ListItem extends FormData {
id: number;
description?: string;
gpu_count?: number;
replicas?: number;
status?: string;
created_at?: string;
updated_at?: string;
@@ -11,7 +11,7 @@ import { FormData } from '../config/types';
import Ports from './ports';
interface BasicProps {
page: 'template' | 'instance';
page?: 'template' | 'instance';
}
const Basic: React.FC<BasicProps> = ({ page = 'template' }) => {
@@ -45,7 +45,6 @@ const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => {
<CInputNumber
min={1}
max={65535}
disabled={index === 0}
precision={0}
value={item.value}
onChange={(value) => {
-11
View File
@@ -6,7 +6,6 @@ import styled from 'styled-components';
import PageBox from '../_components/page-box';
import Appearance from './components/appearance';
import ModifyPasswordn from './components/modify-password';
import PublicKey from './components/public-key';
const Wrapper = styled.div`
.ant-page-header-heading {
@@ -27,11 +26,6 @@ const Profile: React.FC = () => {
const items: TabsProps['items'] = useMemo(() => {
if (initialState?.currentUser?.source !== 'Local') {
return [
{
key: 'public-key',
label: 'SSH Public Key',
children: <PublicKey />
},
{
key: 'appearance',
label: intl.formatMessage({ id: 'common.appearance' }),
@@ -45,11 +39,6 @@ const Profile: React.FC = () => {
label: intl.formatMessage({ id: 'users.form.updatepassword' }),
children: <ModifyPasswordn />
},
{
key: 'public-key',
label: 'SSH 公钥',
children: <PublicKey />
},
{
key: 'appearance',
label: intl.formatMessage({ id: 'common.appearance' }),