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
+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;