fix: alignment api

This commit is contained in:
jialin
2026-05-11 19:49:21 +08:00
committed by jialin
parent 9e0dd5b003
commit b13ba0a229
33 changed files with 738 additions and 365 deletions
+11
View File
@@ -27,3 +27,14 @@ export const initialPasswordAtom = atomWithStorage<string>(
'initialPassword',
''
);
export const getCurrentOrganizationId = (): string => {
try {
const raw = localStorage.getItem('currentOrganizationId');
if (raw) {
const value = JSON.parse(raw);
if (value != null) return String(value);
}
} catch {}
return 'gpustack-organization-1';
};
+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_nujs54seb6.js',
iconUrl: '//at.alicdn.com/t/c/font_4613488_t4uxqkp138r.js',
isDarkTheme: userSettings.isDarkTheme,
defaultColorPrimary: COLOR_PRIMARY
}}
+5 -3
View File
@@ -11,7 +11,7 @@ export default {
'gpuservice.template.imagePullPolicy.never': 'Never',
'gpuservice.template.command': 'Container Startup Command',
'gpuservice.template.command.placeholder':
'One argument per line, e.g.:\n/bin/bash\n-c\nyour command',
'Separate arguments with spaces; wrap arguments containing spaces in quotes, e.g.: /bin/bash -c "echo hello world"',
'gpuservice.template.mountPath': 'Mount Path',
'gpuservice.template.containerDisk': 'Container Disk (Gi)',
'gpuservice.template.memory': 'Memory (Gi)',
@@ -48,14 +48,16 @@ export default {
'gpuservice.instance.gpuCount.max':
'The current instance type supports at most {count} GPU(s)',
'gpuservice.instance.stock': 'Stock',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'Memory',
'gpuservice.instance.memory': 'Memory',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.search.type.placeholder':
'Search by name, VRAM, memory or vCPU',
'gpuservice.instance.search.template.placeholder':
'Search by template name, image or mount path',
'gpuservice.instance.template.image': 'Image',
'gpuservice.instance.template.mount': 'Mount',
'gpuservice.instance.connect': 'Connect',
'gpuservice.instance.connect.copySshCommand': 'Copy SSH Command',
'gpuservice.storage': 'Storage',
'gpuservice.storage.add': 'Add Storage',
'gpuservice.storage.edit': 'Edit Storage',
+5 -3
View File
@@ -11,7 +11,7 @@ export default {
'gpuservice.template.imagePullPolicy.never': '取得しない',
'gpuservice.template.command': 'コンテナ起動コマンド',
'gpuservice.template.command.placeholder':
'1行に1つの引数、例:\n/bin/bash\n-c\nyour command',
'引数はスペースで区切り、スペースを含む引数は引用符で囲んでください。例:/bin/bash -c "echo hello world"',
'gpuservice.template.mountPath': 'マウントパス',
'gpuservice.template.containerDisk': 'コンテナディスク (Gi)',
'gpuservice.template.memory': 'メモリ (Gi)',
@@ -46,14 +46,16 @@ export default {
'gpuservice.instance.gpuCount.max':
'現在のインスタンスタイプは最大 {count} 個の GPU をサポートします',
'gpuservice.instance.stock': '在庫',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'メモリ',
'gpuservice.instance.memory': 'Memory',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.search.type.placeholder':
'名前、VRAM、メモリまたは vCPU で検索',
'gpuservice.instance.search.template.placeholder':
'テンプレート名、イメージまたはマウントパスで検索',
'gpuservice.instance.template.image': 'イメージ',
'gpuservice.instance.template.mount': 'マウント',
'gpuservice.instance.connect': '接続',
'gpuservice.instance.connect.copySshCommand': 'SSH コマンドをコピー',
'gpuservice.storage': 'ストレージ',
'gpuservice.storage.add': 'ストレージを追加',
'gpuservice.storage.edit': 'ストレージを編集',
+5 -3
View File
@@ -12,7 +12,7 @@ export default {
'gpuservice.template.imagePullPolicy.never': 'Никогда не загружать',
'gpuservice.template.command': 'Команда запуска контейнера',
'gpuservice.template.command.placeholder':
'Один аргумент на строку, например:\n/bin/bash\n-c\nyour command',
'Разделяйте аргументы пробелами; аргументы с пробелами заключайте в кавычки, например: /bin/bash -c "echo hello world"',
'gpuservice.template.mountPath': 'Путь монтирования',
'gpuservice.template.containerDisk': 'Диск контейнера (Gi)',
'gpuservice.template.memory': 'Память (Gi)',
@@ -48,14 +48,16 @@ export default {
'gpuservice.instance.gpuCount.max':
'Текущий тип экземпляра поддерживает максимум {count} GPU',
'gpuservice.instance.stock': 'Остаток',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'Память',
'gpuservice.instance.memory': 'Память',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.search.type.placeholder':
'Поиск по имени, VRAM, памяти или vCPU',
'gpuservice.instance.search.template.placeholder':
'Поиск по имени шаблона, образу или пути монтирования',
'gpuservice.instance.template.image': 'Образ',
'gpuservice.instance.template.mount': 'Монтирование',
'gpuservice.instance.connect': 'Подключение',
'gpuservice.instance.connect.copySshCommand': 'Скопировать команду SSH',
'gpuservice.storage': 'Хранилище',
'gpuservice.storage.add': 'Добавить хранилище',
'gpuservice.storage.edit': 'Редактировать хранилище',
+5 -3
View File
@@ -11,7 +11,7 @@ export default {
'gpuservice.template.imagePullPolicy.never': 'Asla çekme',
'gpuservice.template.command': 'Konteyner Başlatma Komutu',
'gpuservice.template.command.placeholder':
'Her satırda bir argüman, örn.:\n/bin/bash\n-c\nyour command',
'Argümanları boşlukla ayırın; boşluk içeren argümanları tırnak içine alın, örn.: /bin/bash -c "echo hello world"',
'gpuservice.template.mountPath': 'Bağlama Yolu',
'gpuservice.template.containerDisk': 'Konteyner Diski (Gi)',
'gpuservice.template.memory': 'Bellek (Gi)',
@@ -47,14 +47,16 @@ export default {
'gpuservice.instance.gpuCount.max':
'Mevcut örnek türü en fazla {count} GPU destekler',
'gpuservice.instance.stock': 'Stok',
'gpuservice.instance.memory': 'VRAM',
'gpuservice.instance.ram': 'Bellek',
'gpuservice.instance.memory': 'Bellek',
'gpuservice.instance.ram': 'RAM',
'gpuservice.instance.search.type.placeholder':
"Ada, VRAM, belleğe veya vCPU'ya göre ara",
'gpuservice.instance.search.template.placeholder':
'Şablon adına, imaja veya bağlama yoluna göre ara',
'gpuservice.instance.template.image': 'İmaj',
'gpuservice.instance.template.mount': 'Bağlama',
'gpuservice.instance.connect': 'Bağlan',
'gpuservice.instance.connect.copySshCommand': 'SSH Komutunu Kopyala',
'gpuservice.storage': 'Depolama',
'gpuservice.storage.add': 'Depolama Ekle',
'gpuservice.storage.edit': 'Depolamayı Düzenle',
+3 -1
View File
@@ -11,7 +11,7 @@ export default {
'gpuservice.template.imagePullPolicy.never': '从不拉取',
'gpuservice.template.command': '容器启动命令',
'gpuservice.template.command.placeholder':
'每行一个参数,例如:\n/bin/bash\n-c\nyour command',
'使用空格分隔参数;含空格的参数请用引号包裹,例如:/bin/bash -c "echo hello world"',
'gpuservice.template.mountPath': '挂载路径',
'gpuservice.template.containerDisk': '容器磁盘(Gi)',
'gpuservice.template.memory': '内存(Gi)',
@@ -52,6 +52,8 @@ export default {
'搜索模板名称、镜像或挂载路径',
'gpuservice.instance.template.image': '镜像',
'gpuservice.instance.template.mount': '挂载',
'gpuservice.instance.connect': '连接',
'gpuservice.instance.connect.copySshCommand': '复制 SSH 命令',
'gpuservice.storage': '存储',
'gpuservice.storage.add': '添加存储',
'gpuservice.storage.edit': '编辑存储',
+22 -3
View File
@@ -1,4 +1,5 @@
import { request } from '@umijs/max';
import { omitPathParams } from '../../utils';
import { InstanceTypeItem, ListItem } from '../config/types';
export const GPU_SERVICE_INSTANCES_API = (params: {
@@ -19,6 +20,9 @@ export async function queryGPUServiceInstances(
},
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<ListItem>>(
GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
@@ -26,7 +30,7 @@ export async function queryGPUServiceInstances(
}),
{
method: 'GET',
params,
params: omitPathParams(params),
cancelToken: options?.token
}
);
@@ -40,6 +44,9 @@ export async function createGPUServiceInstance(
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
@@ -62,6 +69,9 @@ export async function updateGPUServiceInstance(
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
`${GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
@@ -83,6 +93,9 @@ export async function deleteGPUServiceInstance(
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request(
`${GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
@@ -101,11 +114,14 @@ export async function queryGPUServiceInstanceTypes(
params: Global.K8sSearchParams & { clusterID?: number },
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<InstanceTypeItem>>(
GPU_SERVICE_INSTANCES_TYPE_API({ clusterID: params.clusterID }),
{
method: 'GET',
params,
params: omitPathParams(params),
cancelToken: options?.token
}
);
@@ -118,11 +134,14 @@ export async function queryGPUServiceInstanceTypeItems(
},
options?: any
) {
if (!params.clusterID) {
return;
}
return request<InstanceTypeItem>(
`${GPU_SERVICE_INSTANCES_TYPE_API({ clusterID: params.clusterID })}/${params.name}`,
{
method: 'GET',
params,
params: omitPathParams(params),
cancelToken: options?.token
}
);
@@ -1,3 +1,4 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import Separator from '@/pages/llmodels/components/separator';
import { SearchOutlined } from '@ant-design/icons';
@@ -61,20 +62,32 @@ const AddModal: React.FC<AddModalProps> = ({
}) => {
const intl = useIntl();
const form = useRef<any>(null);
const autoSelectedRef = useRef(false);
const [selectedInstanceType, setSelectedInstanceType] = useState<string>();
const [selectedManufacturer, setSelectedManufacturer] = useState<string>();
const [templateId, setTemplateId] = useState<number>();
const [instanceKeyword, setInstanceKeyword] = useState('');
const [templateKeyword, setTemplateKeyword] = useState('');
const { detailData, fetchData } = useQueryInstanceTypes();
const {
detailData,
loading: instanceTypesLoading,
fetchData
} = useQueryInstanceTypes();
const { detailData: templatesData, fetchData: fetchTemplates } =
useQueryTemplates();
useEffect(() => {
if (open) {
autoSelectedRef.current = false;
fetchData({});
fetchTemplates({ page: 1, perPage: 100 });
} else {
setSelectedInstanceType(undefined);
setSelectedManufacturer(undefined);
setTemplateId(undefined);
setInstanceKeyword('');
setTemplateKeyword('');
}
}, [open, fetchData, fetchTemplates]);
@@ -119,6 +132,59 @@ const AddModal: React.FC<AddModalProps> = ({
);
}, [templateKeyword, manufacturerMatchedTemplates]);
useEffect(() => {
if (!open) return;
if (action !== PageAction.CREATE) return;
if (autoSelectedRef.current) return;
if (!instanceTypeList.length) return;
if (!templatesData) return;
const firstInstanceType = instanceTypeList[0];
const manufacturer = firstInstanceType.spec?.manufacturer;
const name = firstInstanceType.metadata?.name;
const matchedTemplate = manufacturer
? templateList.find((t) => t.manufacturer === manufacturer)
: templateList[0];
autoSelectedRef.current = true;
setSelectedInstanceType(name);
setSelectedManufacturer(manufacturer);
if (matchedTemplate) {
setTemplateId(matchedTemplate.id);
}
const applyToForm = () => {
const currentSpec = form.current?.getFieldsValue()?.spec || {};
const updatedSpec = {
...currentSpec,
type: name,
resources: { ...(currentSpec.resources || {}), accelerator: '1' }
};
if (matchedTemplate) {
form.current?.setFieldsValue({
manufacturer: matchedTemplate.manufacturer,
spec: {
...updatedSpec,
...matchedTemplate.spec,
resources: {
...(updatedSpec.resources || {}),
...(matchedTemplate.spec?.resources || {})
}
}
});
} else {
form.current?.setFieldsValue({ spec: updatedSpec });
}
};
if (form.current) {
applyToForm();
} else {
queueMicrotask(applyToForm);
}
}, [open, action, instanceTypeList, templateList, templatesData]);
const handleSubmit = () => {
form.current?.submit();
};
@@ -229,15 +295,12 @@ const AddModal: React.FC<AddModalProps> = ({
onChange={(e) => setInstanceKeyword(e.target.value)}
/>
</div>
{filteredInstanceTypes.length > 0 ? (
<InstanceTypeList
value={selectedInstanceType}
dataList={filteredInstanceTypes}
onChange={handleInstanceTypeChange}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
<InstanceTypeList
value={selectedInstanceType}
dataList={filteredInstanceTypes}
loading={instanceTypesLoading}
onChange={handleInstanceTypeChange}
/>
</PanelBody>
</ColumnWrapper>
<Separator></Separator>
@@ -0,0 +1,155 @@
import { AutoTooltip, IconFont, StatusTag, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import styled from 'styled-components';
import {
convertKiToGi,
InstanceTypePhaseLabelMap,
InstanceTypePhaseStatus
} from '../config';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
const Title = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
color: var(--ant-color-text);
font-weight: 500;
`;
const Meta = styled.div`
display: grid;
gap: 8px;
color: var(--ant-color-text-secondary);
font-size: 13px;
.meta-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--ant-color-text-tertiary);
}
.dot {
width: 3px;
height: 3px;
border-radius: 50%;
background-color: var(--ant-color-text-quaternary);
flex: none;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.meta-icon {
font-size: 14px;
color: var(--ant-color-text-quaternary);
}
`;
interface InstanceTypeItemProps {
item: InstanceTypeItemModel;
showStatus?: boolean;
}
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
item,
showStatus = true
}) => {
const intl = useIntl();
const name = item.metadata?.name;
const acceleratable = item.spec?.acceleratable;
const product = item.spec?.product;
const displayName = product || (!acceleratable ? 'CPU' : name);
const manufacturer = item.spec?.manufacturer?.toUpperCase();
return (
<>
<Title>
<Flex gap={8} align="center">
<AutoTooltip ghost minWidth={20} maxWidth={180}>
{displayName}
</AutoTooltip>
{acceleratable && manufacturer && (
<span
style={{
color: 'var(--ant-color-text-tertiary)',
fontWeight: 400
}}
>
<ThemeTag color="purple">{manufacturer}</ThemeTag>
</span>
)}
</Flex>
{showStatus && (
<Flex gap={8} align="center">
{item.status?.phase && (
<StatusTag
statusValue={{
status:
InstanceTypePhaseStatus[item.status.phase] ?? 'inactive',
text:
InstanceTypePhaseLabelMap[item.status.phase] ??
item.status.phase,
message: ''
}}
/>
)}
</Flex>
)}
</Title>
<Meta>
<span style={{ display: 'flex', height: 15 }}>
{acceleratable && (
<span className="meta-row">
<span className="meta-item">
<IconFont type="icon-gpu1" className="meta-icon" />
<Flex align="center" gap={4}>
<span>
{intl.formatMessage({ id: 'gpuservice.instance.memory' })}
</span>
<span>{convertKiToGi(item.spec?.memory) ?? '-'}</span>
</Flex>
</span>
<span className="dot"></span>
<span className="meta-item">
<IconFont type="icon-cube" className="meta-icon" />
<Flex align="center" gap={4}>
<span>
{intl.formatMessage({ id: 'gpuservice.instance.stock' })}
</span>
<span>{item.status?.accelerator?.remaining ?? '-'}</span>
</Flex>
</span>
</span>
)}
</span>
<span className="meta-row">
<span className="meta-item">
<IconFont type="icon-ram-02" className="meta-icon" />
<Flex align="center" gap={4}>
<span>
{intl.formatMessage({ id: 'gpuservice.instance.ram' })}
</span>
<span>{item.status?.ram?.capacity ?? '-'}</span>
</Flex>
</span>
<span className="dot"></span>
<span className="meta-item">
<IconFont type="icon-cpu" className="meta-icon" />
<Flex align="center" gap={4}>
<span>vCPU</span>
<span>{item.status?.cpu?.capacity ?? '-'}</span>
</Flex>
</span>
</span>
</Meta>
</>
);
};
export default InstanceTypeItem;
@@ -1,13 +1,11 @@
import { AutoTooltip, StatusTag, TemplateCard } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Tag } from 'antd';
import FileSkeleton from '@/pages/llmodels/components/model-source/file-skeleton';
import { TemplateCard } from '@gpustack/core-ui';
import { Empty, Spin } from 'antd';
import _ from 'lodash';
import styled from 'styled-components';
import {
InstanceTypePhaseLabelMap,
InstanceTypePhaseStatus,
InstanceTypePhaseValueMap
} from '../config';
import { InstanceTypeItem } from '../config/types';
import { InstanceTypePhaseValueMap } from '../config';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
import InstanceTypeItem from './instance-type-item';
const TypeGrid = styled.div`
display: flex;
@@ -15,53 +13,42 @@ const TypeGrid = styled.div`
gap: 16px;
`;
const TypeName = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
color: var(--ant-color-text);
font-weight: 500;
`;
const TypeMeta = styled.div`
display: grid;
gap: 8px;
color: var(--ant-color-text-secondary);
font-size: 13px;
.meta-row {
display: flex;
align-items: center;
gap: 16px;
}
.icon {
color: var(--ant-color-text-quaternary);
}
`;
interface InstanceTypeListProps {
value?: string;
onChange?: (item: InstanceTypeItem) => void;
dataList?: InstanceTypeItem[];
onChange?: (item: InstanceTypeItemModel) => void;
dataList?: InstanceTypeItemModel[];
loading?: boolean;
}
const isAvailable = (item: InstanceTypeItem) =>
const isAvailable = (item: InstanceTypeItemModel) =>
item.status?.phase === InstanceTypePhaseValueMap.Active;
const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
value,
onChange,
dataList = []
dataList = [],
loading
}) => {
const intl = useIntl();
const handleSelect = (item: InstanceTypeItem) => {
const handleSelect = (item: InstanceTypeItemModel) => {
if (!isAvailable(item)) return;
onChange?.(item);
};
if (!dataList.length) {
if (loading) {
return (
<Spin spinning size="middle">
<TypeGrid style={{ minHeight: 200 }}>
{_.times(6, (index: number) => (
<FileSkeleton key={index} counts={3} itemHeight={106} />
))}
</TypeGrid>
</Spin>
);
}
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
return (
<TypeGrid>
{dataList.map((item) => {
@@ -78,58 +65,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
disabled={disabled}
onClick={() => handleSelect(item)}
>
<TypeName>
<Flex gap={8} align="center">
<AutoTooltip ghost minWidth={20}>
{name}
</AutoTooltip>
</Flex>
<Flex gap={8} align="center">
{item.status?.phase && (
<StatusTag
statusValue={{
status:
InstanceTypePhaseStatus[item.status.phase] ??
'inactive',
text:
InstanceTypePhaseLabelMap[item.status.phase] ??
item.status.phase,
message: ''
}}
/>
)}
{item.spec?.acceleratable && (
<Tag
style={{
fontWeight: 400,
color: 'var(--ant-color-text-tertiary)'
}}
>
<span>
{intl.formatMessage({ id: 'gpuservice.instance.stock' })}{' '}
{item.status?.accelerator?.remaining ?? '-'}
</span>
</Tag>
)}
</Flex>
</TypeName>
<TypeMeta>
<span style={{ display: 'flex', height: 15 }}>
{item.spec?.acceleratable && (
<span className="meta-row">
{intl.formatMessage({ id: 'gpuservice.instance.memory' })}{' '}
{item.spec?.memory ?? '-'}
</span>
)}
</span>
<span className="meta-row gap-16">
<span>
{intl.formatMessage({ id: 'gpuservice.instance.ram' })}{' '}
{item.status?.ram?.capacity ?? '-'}
</span>
<span>vCPU {item.status?.cpu?.capacity ?? '-'}</span>
</span>
</TypeMeta>
<InstanceTypeItem item={item} />
</TemplateCard>
);
})}
@@ -40,7 +40,7 @@ export const status: Record<string, StatusType> = {
[InstanceStatusValueMap.InitializeFailed]: StatusMaps.error,
[InstanceStatusValueMap.Initialized]: StatusMaps.transitioning,
[InstanceStatusValueMap.Preparing]: StatusMaps.transitioning,
[InstanceStatusValueMap.NotReady]: StatusMaps.warning,
[InstanceStatusValueMap.NotReady]: StatusMaps.error,
[InstanceStatusValueMap.Ready]: StatusMaps.success
};
@@ -90,13 +90,19 @@ export const StorageModeValueMap = {
// Constant SSH public key resource name used when SSH is enabled
export const DEFAULT_SSH_PUBLIC_KEY_NAME = 'default';
const KI_TO_GI = 1024 * 1024;
const GI_DIVISOR: Record<string, number> = {
Ki: 1024 * 1024,
Mi: 1024,
Gi: 1
};
export const convertKiToGi = (value?: string): string | undefined => {
if (!value) return value;
const match = /^(-?\d+(?:\.\d+)?)Ki$/.exec(value);
const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)$/.exec(value);
if (!match) return value;
return `${_.round(Number(match[1]) / KI_TO_GI, 2)}Gi`;
const [, num, unit] = match;
if (unit === 'Ti') return `${_.round(Number(num), 2)}Ti`;
return `${_.round(Number(num) / GI_DIVISOR[unit], 2)}Gi`;
};
export const transformInstanceTypeResource = (
+40 -23
View File
@@ -82,7 +82,12 @@ export interface ListItem extends Omit<FormData, 'metadata'> {
};
}
type Quality = `${number}Ki` | `${number}Gi`;
type Quality =
| `${number}`
| `${number}Ki`
| `${number}Mi`
| `${number}Gi`
| `${number}Ti`;
export interface InstanceTypeResource {
capacity?: Quality;
@@ -90,30 +95,42 @@ export interface InstanceTypeResource {
remaining?: Quality;
}
export interface InstanceTypeStatus {
accelerator: InstanceTypeResource;
cpu: InstanceTypeResource;
localStorage: InstanceTypeResource;
phase: string;
phaseMessage?: string;
ram: InstanceTypeResource;
}
export interface InstanceTypeMetadata {
name: string;
namespace?: string;
uid?: string;
resourceVersion?: string;
creationTimestamp?: string;
generateName?: string;
generation?: number;
finalizers?: string[];
}
export interface InstanceTypeSpec {
acceleratable: boolean;
group: string;
computeCapability?: string;
family?: string;
manufacturer?: string;
memory?: string;
product?: string;
sliced?: number;
}
export interface InstanceTypeItem {
apiVersion: string;
kind: string;
metadata: {
name: string;
namespace: string;
};
id: number;
spec: {
acceleratable: boolean;
computeCapability?: string;
family?: string;
group: string;
manufacturer?: string;
memory?: string;
product?: string;
sliced?: number;
};
status: {
accelerator: InstanceTypeResource;
cpu: InstanceTypeResource;
localStorage: InstanceTypeResource;
phase: string;
phaseMessage?: string;
ram: InstanceTypeResource;
};
metadata: InstanceTypeMetadata;
spec: InstanceTypeSpec;
status: InstanceTypeStatus;
}
+28 -44
View File
@@ -21,7 +21,6 @@ import {
import useGetSshkey from '../../public-keys/services/use-get-sshkey';
import { DefaultImagePullPolicy } from '../../templates/config';
import TemplateBasicForm from '../../templates/forms/basic';
import { DEFAULT_SSH_PUBLIC_KEY_NAME } from '../config';
import { FormData, InstancePort, ListItem } from '../config/types';
import Basic from './basic';
import InstanceTypeFormItem from './instance-type';
@@ -53,19 +52,19 @@ const TABKeysMap = {
const requiredFields = {
[TABKeysMap.BASIC]: {
sort: 1,
fields: ['name']
fields: ['metadata.name']
},
[TABKeysMap.INSTANCE_TYPE]: {
sort: 2,
fields: ['type']
fields: ['spec.type', 'spec.resources.accelerator']
},
[TABKeysMap.TEMPLATE]: {
sort: 3,
fields: ['image', 'ports']
fields: ['spec.image', 'spec.ports', 'spec.env']
},
[TABKeysMap.STORAGE]: {
sort: 4,
fields: ['volume']
fields: ['spec.volume.persistent.name', 'spec.volume.ephemeral.capacity']
}
};
@@ -143,7 +142,8 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
const ports = (Form.useWatch(['spec', 'ports'], form) ||
[]) as InstancePort[];
const hasSshPort = useMemo(
() => ports.some((p) => Number(p?.port) === SSH_PORT),
() =>
ports.some((p) => Number(p?.port) === SSH_PORT && p.protocol === 'TCP'),
[ports]
);
@@ -151,12 +151,25 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
scrollTabsRef.current?.handleTargetChange(key);
};
const { handleOnFinishFailed } = useFinishFailed({
const { handleOnFinishFailed: rawHandleOnFinishFailed } = useFinishFailed({
requiredFields,
onTargetChange,
updateActiveKey
});
// useFinishFailed only matches errorFields[].name[0] against `fields`,
// but our form fields are nested (e.g. ['spec','type']), so collapse each
// error path into a single dotted string before delegating.
const handleOnFinishFailed = (errorInfo: any) => {
const errorFields = (errorInfo?.errorFields || []).map((field: any) => ({
...field,
name: [
Array.isArray(field.name) ? field.name.join('.') : String(field.name)
]
}));
rawHandleOnFinishFailed({ ...errorInfo, errorFields });
};
useEffect(() => {
if (!open) {
form.resetFields();
@@ -180,43 +193,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}
}, [action, currentData, form, open, namespace]);
const buildPayload = (values: InstanceFormValues): FormData => {
const acceleratorVal = values.spec?.resources?.accelerator;
const acceleratorStr =
acceleratorVal !== undefined &&
acceleratorVal !== null &&
(acceleratorVal as unknown) !== ''
? String(acceleratorVal)
: undefined;
const spec: FormData['spec'] = {
...values.spec,
resources: {
...values.spec?.resources,
accelerator: acceleratorStr
}
} as FormData['spec'];
// SSH public key — include only when checkbox is checked AND port 22 is in ports
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 };
} else {
delete (spec as any).sshPublicKey;
}
return {
metadata: {
name: values.metadata?.name,
namespace: values.metadata?.namespace || namespace
},
spec
} as FormData;
};
const handleFinish = async (values: InstanceFormValues) => {
const payload = buildPayload(values);
await onFinish(values);
};
@@ -265,9 +242,16 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
resources: {
accelerator: '1'
},
volume: { persistent: { name: '' } },
sshPublicKey: {
name: 'gpustack-organization-ssh-public-key'
},
volume: {
ephemeral: {
capacity: '20Gi'
},
persistent: {
name: ''
}
}
},
enable_ssh: false
@@ -1,10 +1,14 @@
import { AutoTooltip, InputNumber as CInputNumber } from '@gpustack/core-ui';
import { InputNumber as CInputNumber } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form, Tag } from 'antd';
import { Form } from 'antd';
import { useEffect, useMemo } from 'react';
import styled from 'styled-components';
import InstanceTypeItem from '../components/instance-type-item';
import { InstanceTypePhaseValueMap } from '../config';
import { FormData, InstanceTypeItem } from '../config/types';
import {
FormData,
InstanceTypeItem as InstanceTypeItemModel
} from '../config/types';
import useQueryInstanceTypes from '../services/use-query-instance-types';
const FieldBlock = styled.div`
@@ -12,87 +16,31 @@ const FieldBlock = styled.div`
`;
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: flex;
flex-direction: column;
gap: 4px;
color: var(--ant-color-text-secondary);
font-size: 13px;
`;
interface InstanceTypePickerProps {
value?: string;
dataList: InstanceTypeItem[];
dataList: InstanceTypeItemModel[];
}
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
value,
dataList
}) => {
const intl = useIntl();
const selected = useMemo(() => {
return dataList.find((item) => (item.metadata?.name || '') === value);
}, [value, dataList]);
if (!selected) return null;
return (
<>
<SelectedCard>
<div>
<SummaryTitle>
<Flex gap={16} align="center">
<AutoTooltip ghost minWidth={20}>
{selected?.metadata?.name || '-'}
</AutoTooltip>
{selected && selected.spec?.acceleratable && (
<Tag
style={{
fontWeight: 400,
color: 'var(--ant-color-text-tertiary)'
}}
>
{intl.formatMessage({ id: 'gpuservice.instance.stock' })}{' '}
{selected?.status?.accelerator?.remaining ?? '-'}
</Tag>
)}
</Flex>
</SummaryTitle>
<SummaryMeta>
{selected?.spec?.acceleratable && (
<span>
{intl.formatMessage({ id: 'gpuservice.instance.memory' })}{' '}
{selected?.spec?.memory ?? '-'}
</span>
)}
<Flex gap={16}>
<span>
{intl.formatMessage({ id: 'gpuservice.instance.ram' })}{' '}
{selected?.status?.ram?.capacity ?? '-'}
</span>
<span>vCPU {selected?.status?.cpu?.capacity ?? '-'}</span>
</Flex>
</SummaryMeta>
</div>
</SelectedCard>
</>
<SelectedCard>
<InstanceTypeItem item={selected} showStatus={false} />
</SelectedCard>
);
};
@@ -114,8 +62,9 @@ const InstanceTypeFormItem = () => {
}, [dataList, typeName]);
const maxGpuCount = useMemo(() => {
const remaining = selectedInstanceType?.status?.accelerator?.remaining;
const num = remaining ? Number(remaining) : NaN;
const onceMaxRequest =
selectedInstanceType?.status?.accelerator?.onceMaxRequest;
const num = onceMaxRequest ? Number(onceMaxRequest) : NaN;
return Number.isFinite(num) && num > 0 ? num : 1;
}, [selectedInstanceType]);
@@ -1,4 +1,4 @@
import { useModel } from '@@/plugin-model';
import { getCurrentOrganizationId } from '@/atoms/user';
import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Flex, Form, Radio } from 'antd';
@@ -8,7 +8,7 @@ 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, InstanceVolume } from '../config/types';
import { FormData } from '../config/types';
import StorageOverlay from './storage-overlay';
const FieldBlock = styled.div`
@@ -19,33 +19,22 @@ const DEFAULT_TEMP_CAPACITY_GB = 50;
const StorageVolume = () => {
const intl = useIntl();
const { initialState } = useModel('@@initialState');
const { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } =
useQueryStorage();
const [overlayOpen, setOverlayOpen] = useState(false);
const [storageMode, setStorageMode] = useState<string>(
StorageModeValueMap.Existing
StorageModeValueMap.Temporary
);
const form = Form.useFormInstance<FormData>();
const volume = Form.useWatch(['spec', 'volume'], form) as
| InstanceVolume
| undefined;
const namespace = initialState?.currentUser?.org_name || 'default';
const namespace = getCurrentOrganizationId();
useEffect(() => {
fetchStorage({});
}, []);
// const storageMode = useMemo(() => {
// if (volume?.ephemeral && !volume?.persistent) {
// return StorageModeValueMap.Temporary;
// }
// return StorageModeValueMap.Existing;
// }, [volume?.ephemeral, volume?.persistent]);
const storageOptions = useMemo(
() =>
(storageData?.items || []).map((item) => ({
@@ -94,15 +83,15 @@ const StorageVolume = () => {
value={storageMode}
onChange={(e) => handleModeChange(e.target.value)}
options={[
{
label: intl.formatMessage({ id: 'gpuservice.storage.temporary' }),
value: StorageModeValueMap.Temporary
},
{
label: intl.formatMessage({
id: 'gpuservice.storage.persistent'
}),
value: StorageModeValueMap.Existing
},
{
label: intl.formatMessage({ id: 'gpuservice.storage.temporary' }),
value: StorageModeValueMap.Temporary
}
]}
/>
@@ -1,12 +1,50 @@
import { tableSorter } from '@/config/settings';
import { AutoTooltip, DropdownButtons, StatusTag } from '@gpustack/core-ui';
import { ExportOutlined } from '@ant-design/icons';
import {
AutoTooltip,
CopyButton,
DropdownButtons,
StatusTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Flex } from 'antd';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { InstanceStatusLabelMap, rowActionList, status } from '../config';
import { ListItem } from '../config/types';
type ConnectEntry =
| { type: 'ssh'; key: string; command: string }
| { type: 'http'; key: string; port: number; url: string };
const getConnectEntries = (record: ListItem): ConnectEntry[] => {
const ip = record.status?.hostIPs?.[0]?.ip;
const ports = record.status?.ports || [];
const hasSshKey = !!record.spec?.sshPublicKey?.name;
if (!ip) {
return [];
}
return ports
.filter((p) => p.nodePort)
.map<ConnectEntry>((p) => {
const isSsh = hasSshKey && p.protocol === 'TCP' && p.port === 22;
return isSsh
? {
type: 'ssh',
key: `ssh-${p.nodePort}`,
command: `ssh root@${ip} -p ${p.nodePort}`
}
: {
type: 'http',
key: `http-${p.nodePort}`,
port: p.port,
url: `http://${ip}:${p.nodePort}`
};
});
};
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void;
sortOrder: string[];
@@ -23,7 +61,7 @@ const useInstancesColumns = ({
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: ['metadata', 'name'],
key: 'name',
sorter: tableSorter(1),
sorter: false,
ellipsis: {
showTitle: false
},
@@ -33,39 +71,101 @@ const useInstancesColumns = ({
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'gpuservice.instance.connect' }),
key: 'connect',
render: (_text, record: ListItem) => {
const entries = getConnectEntries(record);
if (entries.length === 0) {
return '-';
}
return (
<Flex gap={6} vertical>
{entries.map((entry) =>
entry.type === 'ssh' ? (
<Flex key={entry.key} align="center" style={{ height: 22 }}>
<span
className="text-tertiary"
style={{ fontSize: 12, width: 34, flexShrink: 0 }}
>
SSH
</span>
<CopyButton
text={entry.command}
type="link"
size="small"
tips={intl.formatMessage({
id: 'gpuservice.instance.connect.copySshCommand'
})}
style={{
padding: '0 6px',
height: 20,
display: 'flex',
alignItems: 'center',
borderRadius: 4,
backgroundColor: 'var(--ant-color-fill-tertiary)',
fontSize: 12
}}
/>
</Flex>
) : (
<Flex key={entry.key} align="center" style={{ height: 22 }}>
<span
className="text-tertiary"
style={{ fontSize: 12, width: 34, flexShrink: 0 }}
>
HTTP
</span>
<Button
type="link"
size="small"
href={entry.url}
target="_blank"
rel="noopener noreferrer"
style={{
padding: '0 6px',
height: 20,
display: 'flex',
alignItems: 'center',
fontSize: 12,
fontFamily: 'monospace',
backgroundColor: 'var(--ant-color-fill-tertiary)'
}}
>
{entry.port}
<ExportOutlined style={{ fontSize: 10, marginLeft: 4 }} />
</Button>
</Flex>
)
)}
</Flex>
);
}
},
{
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: ['status', 'phase'],
key: 'status',
sorter: tableSorter(2),
sorter: false,
render: (value: string, record: ListItem) => {
return (
<StatusTag
statusValue={{
status: status[value],
text: InstanceStatusLabelMap[value] || value
text: InstanceStatusLabelMap[value] || value,
message: record?.status?.phaseMessage || ''
}}
></StatusTag>
);
}
},
{
title: intl.formatMessage({ id: 'gpuservice.template.image' }),
dataIndex: ['spec', 'image'],
key: 'image',
sorter: tableSorter(3),
ellipsis: {
showTitle: false
},
render: (value: string) => (
<AutoTooltip ghost>{value || '-'}</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: ['metadata', 'creationTimestamp'],
key: 'creationTimestamp',
sorter: tableSorter(5),
sorter: false,
ellipsis: {
showTitle: false
},
+30 -20
View File
@@ -1,11 +1,11 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrganizationId } from '@/atoms/user';
import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import type { PageActionType } from '@/config/types';
import useTableFetch from '@/hooks/use-table-fetch';
import { ProviderValueMap } from '@/pages/cluster-management/config';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import { useModel } from '@@/plugin-model';
import {
BaseSelect,
DeleteModal,
@@ -33,8 +33,7 @@ import useUpdateInstance from './services/use-update-instance';
const GPUService: React.FC = () => {
const intl = useIntl();
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const namespace = getCurrentOrganizationId();
const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom);
const clusterID = currentCluster?.id;
@@ -43,12 +42,13 @@ const GPUService: React.FC = () => {
[namespace, clusterID]
);
const fetchInstances = useCallback(
const fetchInstances = useMemoizedFn(
async (
params: any,
options?: any
): Promise<Global.PageResponse<ListItem>> => {
if (!clusterID) {
const effectiveClusterID = params.cluster_id ?? clusterID;
if (!effectiveClusterID) {
return {
items: [],
pagination: {
@@ -60,7 +60,7 @@ const GPUService: React.FC = () => {
} as Global.PageResponse<ListItem>;
}
const res = await queryGPUServiceInstances(
{ ...params, namespace, clusterID: params.cluster_id ?? clusterID },
{ ...params, namespace, clusterID: effectiveClusterID },
options
);
const total = res.items?.length ?? 0;
@@ -74,8 +74,7 @@ const GPUService: React.FC = () => {
perPage
}
};
},
[namespace, clusterID]
}
);
const {
@@ -96,7 +95,8 @@ const GPUService: React.FC = () => {
key: PaginationKey.Instances,
fetchAPI: fetchInstances,
deleteAPI: deleteInstance,
watch: true,
watch: false,
polling: true,
API: GPU_SERVICE_INSTANCES_API({ namespace, clusterID }),
contentForDelete: intl.formatMessage({ id: 'gpuservice.instance' })
});
@@ -134,18 +134,24 @@ const GPUService: React.FC = () => {
const k8sClusters = clusters.filter(
(item: any) => item.provider === ProviderValueMap.Kubernetes
);
if (k8sClusters.length > 0) {
const firstCluster = k8sClusters[0];
if (k8sClusters.length === 0) {
return;
}
const storedCluster = k8sClusters.find(
(item: any) => item.id === currentCluster?.id
);
const targetCluster = storedCluster ?? k8sClusters[0];
if (!storedCluster) {
setCurrentCluster({
...firstCluster,
label: firstCluster.name,
value: firstCluster.id
});
handleQueryChange({
cluster_id: firstCluster.id,
page: 1
...targetCluster,
label: targetCluster.name,
value: targetCluster.id
});
}
handleQueryChange({
cluster_id: targetCluster.id,
page: 1
});
});
return () => {
cancelClusterRequest();
@@ -202,7 +208,11 @@ const GPUService: React.FC = () => {
if (val === 'edit') {
handleEditInstance(row);
} else if (val === 'delete') {
handleDelete({ ...row, name: row.metadata?.name });
handleDelete({
...row,
name: row.metadata?.name,
id: row.metadata?.name as any
});
}
});
@@ -225,7 +235,7 @@ const GPUService: React.FC = () => {
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-cloud-outlined" />}
filters={_.omit(queryParams, ['sort_by'])}
filters={_.pick(queryParams, ['search', 'manufacturer'])}
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.instance.nofound'
})}
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { useModel } from '@@/plugin-model';
import { getCurrentOrganizationId } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
@@ -12,8 +12,7 @@ interface CreateInstanceParams {
}
export default function useCreateInstance() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { useModel } from '@@/plugin-model';
import { getCurrentOrganizationId } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
@@ -13,8 +13,7 @@ interface UpdateInstanceParams {
}
export default function useUpdateInstance() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
+4 -4
View File
@@ -1,7 +1,6 @@
import { INPUT_WIDTH } from '@/constants';
import { Input as CInput, Textarea, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Form } from 'antd';
import { Button, Form, message } from 'antd';
import React, { useEffect } from 'react';
import PageBox from '../../_components/page-box';
import useGetSshkey from './services/use-get-sshkey';
@@ -36,6 +35,7 @@ const GPUServicePublicKeys: React.FC = () => {
await updateSshkey({
data: values
});
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
// ignore
}
@@ -44,7 +44,7 @@ const GPUServicePublicKeys: React.FC = () => {
return (
<PageBox>
<Form
style={{ width: INPUT_WIDTH.large }}
style={{ width: 700 }}
name="gpuServicePublicKeyForm"
form={form}
onFinish={onFinish}
@@ -76,7 +76,7 @@ const GPUServicePublicKeys: React.FC = () => {
trim={false}
alwaysFocus
autoSize={{ minRows: 8, maxRows: 16 }}
style={{ width: INPUT_WIDTH.large, minHeight: 186 }}
style={{ width: 700, minHeight: 186 }}
/>
</Form.Item>
<Button
+18 -2
View File
@@ -1,4 +1,5 @@
import { request } from '@umijs/max';
import { omitPathParams } from '../../utils';
import { ListItem, StorageClassItem } from '../config/types';
export const GPU_SERVICE_STORAGE_API = (params: {
@@ -17,6 +18,9 @@ export async function queryGPUServiceStorage(
},
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<ListItem>>(
GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
@@ -24,7 +28,7 @@ export async function queryGPUServiceStorage(
}),
{
method: 'GET',
params,
params: omitPathParams(params),
cancelToken: options?.token
}
);
@@ -38,6 +42,9 @@ export async function createGPUServiceStorage(
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
@@ -60,6 +67,9 @@ export async function updateGPUServiceStorage(
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
`${GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
@@ -78,6 +88,9 @@ export async function deleteGPUServiceStorage(params: {
clusterID?: number;
id: number;
}) {
if (!params.clusterID) {
return;
}
return request(
`${GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
@@ -95,11 +108,14 @@ export async function queryStorageClass(
},
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<StorageClassItem>>(
STORAGE_CLASS_API({ clusterID: params.clusterID }),
{
method: 'GET',
params,
params: omitPathParams(params),
cancelToken: options?.token
}
);
@@ -1,5 +1,25 @@
import { StatusMaps } from '@/config';
import { StatusType } from '@/config/types';
import { icons } from '@gpustack/core-ui';
export const StoragePhaseValueMap = {
Available: 'Available',
Unavailable: 'Unavailable',
Pending: 'Pending'
};
export const StoragePhaseLabelMap: Record<string, string> = {
[StoragePhaseValueMap.Available]: 'Available',
[StoragePhaseValueMap.Unavailable]: 'Unavailable',
[StoragePhaseValueMap.Pending]: 'Pending'
};
export const status: Record<string, StatusType> = {
[StoragePhaseValueMap.Available]: StatusMaps.success,
[StoragePhaseValueMap.Unavailable]: StatusMaps.error,
[StoragePhaseValueMap.Pending]: StatusMaps.transitioning
};
export const StorageTypeValueMap = {
Local: 'local',
Shared: 'shared',
+13 -2
View File
@@ -32,12 +32,23 @@ export interface Metadata {
resourceVersion: string;
creationTimestamp: string;
annotations?: Record<string, string>;
finalizers?: string[];
managedFields?: ManagedField[];
}
export interface ListItem extends Omit<FormData, 'metadata'> {
id: number;
export interface ListItem {
metadata: Metadata;
spec: {
type: string;
capacity: string;
accessMode: AccessModeType;
};
status: {
phase: 'Available' | 'Unavailable' | 'Pending';
volume: {
name: string;
};
};
}
export interface StorageClassItem {
@@ -1,10 +1,14 @@
import { tableSorter } from '@/config/settings';
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
import { AutoTooltip, DropdownButtons, StatusTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { rowActionList, StorageTypeLabelMap } from '../config';
import {
rowActionList,
status,
StoragePhaseLabelMap,
StorageTypeLabelMap
} from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
@@ -23,7 +27,7 @@ const useStorageColumns = ({
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: ['metadata', 'name'],
key: 'name',
sorter: tableSorter(1),
sorter: false,
ellipsis: {
showTitle: false
},
@@ -37,7 +41,7 @@ const useStorageColumns = ({
title: intl.formatMessage({ id: 'common.table.type' }),
dataIndex: ['spec', 'type'],
key: 'type',
sorter: tableSorter(2),
sorter: false,
render: (value: string) =>
StorageTypeLabelMap[value]
? intl.formatMessage({ id: StorageTypeLabelMap[value] })
@@ -47,21 +51,28 @@ const useStorageColumns = ({
title: intl.formatMessage({ id: 'gpuservice.storage.capacity' }),
dataIndex: ['spec', 'capacity'],
key: 'capacity',
sorter: tableSorter(3),
sorter: false,
render: (value: string) => value ?? '-'
},
{
title: intl.formatMessage({ id: 'gpuservice.storage.accessMode' }),
dataIndex: ['spec', 'accessMode'],
key: 'accessMode',
sorter: tableSorter(4),
render: (value: string) => value || '-'
title: intl.formatMessage({ id: 'common.table.status' }),
dataIndex: ['status', 'phase'],
key: 'status',
sorter: false,
render: (value: string) => (
<StatusTag
statusValue={{
status: status[value],
text: StoragePhaseLabelMap[value] || value
}}
></StatusTag>
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: ['metadata', 'creationTimestamp'],
key: 'creationTimestamp',
sorter: tableSorter(5),
sorter: false,
ellipsis: {
showTitle: false
},
+14 -9
View File
@@ -1,11 +1,11 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrganizationId } from '@/atoms/user';
import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import type { PageActionType } from '@/config/types';
import useTableFetch from '@/hooks/use-table-fetch';
import { ProviderValueMap } from '@/pages/cluster-management/config';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import { useModel } from '@@/plugin-model';
import {
BaseSelect,
DeleteModal,
@@ -34,8 +34,7 @@ import useUpdateStorage from './services/use-update-storage';
const GPUServiceStorage: React.FC = () => {
const intl = useIntl();
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const namespace = getCurrentOrganizationId();
const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom);
const clusterID = currentCluster?.id;
@@ -49,7 +48,8 @@ const GPUServiceStorage: React.FC = () => {
params: any,
options?: any
): Promise<Global.PageResponse<ListItem>> => {
if (!clusterID) {
const effectiveClusterID = params.cluster_id ?? clusterID;
if (!effectiveClusterID) {
return {
items: [],
pagination: {
@@ -61,7 +61,7 @@ const GPUServiceStorage: React.FC = () => {
} as Global.PageResponse<ListItem>;
}
const res = await queryGPUServiceStorage(
{ ...params, namespace, clusterID: params.cluster_id ?? clusterID },
{ ...params, namespace, clusterID: effectiveClusterID },
options
);
const total = res.items?.length ?? 0;
@@ -98,6 +98,7 @@ const GPUServiceStorage: React.FC = () => {
fetchAPI: fetchStorage,
deleteAPI: deleteStorage,
watch: false,
polling: true,
API: GPU_SERVICE_STORAGE_API({ namespace, clusterID }),
contentForDelete: intl.formatMessage({ id: 'gpuservice.storage' })
});
@@ -184,7 +185,7 @@ const GPUServiceStorage: React.FC = () => {
try {
if (openAddModalStatus.action === PageAction.EDIT) {
await updateStorage({
id: openAddModalStatus.currentData!.id,
id: openAddModalStatus.currentData!.metadata?.name as any,
data
});
} else {
@@ -203,7 +204,11 @@ const GPUServiceStorage: React.FC = () => {
if (val === 'edit') {
handleEditStorage(row);
} else if (val === 'delete') {
handleDelete({ ...row, name: row.metadata?.name });
handleDelete({
...row,
name: row.metadata?.name,
id: row.metadata?.name as any
});
}
});
@@ -226,7 +231,7 @@ const GPUServiceStorage: React.FC = () => {
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-storage-outlined" />}
filters={_.omit(queryParams, ['sort_by'])}
filters={_.pick(queryParams, ['search'])}
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.storage.nofound'
})}
@@ -296,7 +301,7 @@ const GPUServiceStorage: React.FC = () => {
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey="id"
rowKey={(record) => record.metadata?.name as any}
onChange={handleTableChange}
pagination={{
size: 'middle',
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { useModel } from '@@/plugin-model';
import { getCurrentOrganizationId } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
@@ -12,8 +12,7 @@ interface CreateStorageParams {
}
export default function useCreateStorage() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { useModel } from '@@/plugin-model';
import { getCurrentOrganizationId } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
@@ -7,8 +7,7 @@ import { queryGPUServiceStorage } from '../apis';
import { ListItem } from '../config/types';
export default function useQueryStorage() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { useModel } from '@@/plugin-model';
import { getCurrentOrganizationId } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
@@ -13,8 +13,7 @@ interface UpdateStorageParams {
}
export default function useUpdateStorage() {
const { initialState } = useModel('@@initialState');
const namespace = initialState?.currentUser?.org_name || 'default';
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
@@ -35,6 +35,49 @@ export const ImagePullPolicyOptions: {
export const DefaultImagePullPolicy: ImagePullPolicy = 'IfNotPresent';
export const normalizeCommand = (value: string): string[] => {
if (!value) return [];
const tokens: string[] = [];
let current = '';
let inQuote: '"' | "'" | null = null;
let quoteStart = -1;
for (const ch of value) {
if (inQuote) {
if (ch === inQuote) {
inQuote = null;
quoteStart = -1;
} else {
current += ch;
}
} else if (ch === '"' || ch === "'") {
inQuote = ch;
quoteStart = current.length;
} else if (/\s/.test(ch)) {
tokens.push(current);
current = '';
} else {
current += ch;
}
}
if (inQuote && quoteStart >= 0) {
current =
current.slice(0, quoteStart) + inQuote + current.slice(quoteStart);
}
tokens.push(current);
return tokens;
};
export const stringifyCommand = (tokens?: string[]): string => {
if (!tokens?.length) return '';
return tokens
.map((token) => {
if (!/\s/.test(token)) return token;
if (token.startsWith('"') || token.startsWith("'")) return token;
return `"${token}"`;
})
.join(' ');
};
export const templateActions = [
{
label: 'common.button.edit',
@@ -7,10 +7,13 @@ import {
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd';
import _ from 'lodash';
import { useMemo } from 'react';
import { GPUsConfigs } from '../../../resources/config/gpu-driver';
import { ImagePullPolicyOptions } from '../config';
import {
ImagePullPolicyOptions,
normalizeCommand,
stringifyCommand
} from '../config';
import { FormData } from '../config/types';
import Env from './env';
import Ports from './ports';
@@ -96,8 +99,8 @@ const Basic: React.FC<BasicProps> = ({ page = 'template' }) => {
</Form.Item>
<Form.Item<FormData>
name={['spec', 'command']}
normalize={(value: string) => _.split(value, '\n').map(_.trim)}
getValueProps={(value) => ({ value: _.join(value, '\n') })}
normalize={normalizeCommand}
getValueProps={(value) => ({ value: stringifyCommand(value) })}
>
<Textarea
label={intl.formatMessage({ id: 'gpuservice.template.command' })}
@@ -61,13 +61,20 @@ const GPUServiceTemplateForm: React.FC<TemplateFormProps> = forwardRef(
initialValues={{
spec: {
imagePullPolicy: DefaultImagePullPolicy,
command: [],
command: ['tail', '-f', '/dev/null'],
ports: [
{
protocol: 'TCP',
port: 22
}
],
volumeMount: '/workspace',
resources: {
cpu: '1',
ram: '2Gi',
localStorage: '15Gi',
accelerator: ''
},
env: []
}
}}
+13
View File
@@ -0,0 +1,13 @@
import _ from 'lodash';
export const omitPathParams = <T extends Record<string, any>>(
params: T
): Omit<T, 'namespace' | 'clusterID'> => {
return _.omit(params, [
'namespace',
'clusterID',
'page',
'perPage',
'cluster_id'
]) as Omit<T, 'namespace' | 'clusterID'>;
};