refactor: gpu service API alignment

This commit is contained in:
jialin
2026-05-25 22:42:02 +08:00
committed by jialin
parent 81c7e2ee61
commit 88ab927755
90 changed files with 3247 additions and 2023 deletions
@@ -20,11 +20,10 @@ interface NumberSelectionProps {
input?: React.CSSProperties;
};
labelExtra?: React.ReactNode;
maxCount?: number;
onChange?: (value: number) => void;
}
const PRESET_ITEMS = [1, 2, 4, 8];
const NumberSelection: React.FC<NumberSelectionProps> = ({
id,
step = 1,
@@ -36,21 +35,26 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
required,
labelExtra,
className,
maxCount = 8,
style,
onChange
}) => {
const intl = useIntl();
const showCustomInput = max > 8;
const showCustomInput = max > maxCount;
const presetItems = Array.from(
{ length: Math.max(0, maxCount) },
(_, i) => i + 1
);
const computedItems = showCustomInput
? PRESET_ITEMS.filter((n) => n >= min && n <= max)
? presetItems.filter((n) => n >= min && n <= max)
: Array.from(
{ length: Math.max(0, Math.floor((max - min) / step) + 1) },
(_, i) => min + i * step
);
const items = computedItems.length === 0 ? [0] : computedItems;
const [inputValue, setInputValue] = useState<number | null>(() =>
value !== undefined && value !== null && !PRESET_ITEMS.includes(value)
value !== undefined && value !== null && !presetItems.includes(value)
? value
: null
);
@@ -58,7 +62,7 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
useEffect(() => {
if (value === undefined || value === null) {
setInputValue(null);
} else if (PRESET_ITEMS.includes(value)) {
} else if (presetItems.includes(value)) {
setInputValue(null);
} else {
setInputValue(value);
@@ -83,7 +87,6 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
}
onChange?.(inputValue);
};
console.log('value+++', value, 'inputValue', items, inputValue);
return (
<div
id={id}
@@ -137,7 +140,7 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
})}
>
<InputNumber
controls
controls={false}
variant="borderless"
min={min}
max={max}
@@ -146,7 +149,7 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
value={inputValue || undefined}
style={{
fontSize: 14,
width: 140,
width: 60,
height: 32,
backgroundColor: 'var(--ant-color-bg-container)',
...style
@@ -0,0 +1,32 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { Input as CInput } from '@gpustack/core-ui';
import { Form } from 'antd';
import type { NamePath } from 'antd/es/form/interface';
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
interface OwnerPrincipalIdFieldProps {
name?: NamePath;
}
const OwnerPrincipalIdField: React.FC<OwnerPrincipalIdFieldProps> = ({
name = 'owner_principal_id'
}) => {
const currentCluster = useAtomValue(currentClusterAtom);
const ownerPrincipalId = currentCluster?.owner_principal_id;
const form = Form.useFormInstance();
useEffect(() => {
if (ownerPrincipalId != null) {
form.setFieldValue(name, ownerPrincipalId);
}
}, [ownerPrincipalId, name, form]);
return (
<Form.Item name={name} hidden>
<CInput.Input />
</Form.Item>
);
};
export default OwnerPrincipalIdField;
+48 -204
View File
@@ -2,6 +2,7 @@ import { request } from '@umijs/max';
import _ from 'lodash';
import { omitPathParams } from '../../utils';
import {
FormData,
InstanceEvents,
InstanceLog,
InstanceLogQueryParams,
@@ -9,176 +10,89 @@ import {
ListItem
} from '../config/types';
export const GPU_SERVICE_INSTANCES_API = (params: {
namespace: string;
clusterID?: number;
}) =>
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances`;
export const GPU_SERVICE_INSTANCES_API = '/gpu-instances';
export const GPU_SERVICE_INSTANCES_TYPE_API = (params: {
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/instancetypes`;
};
export const GPU_SERVICE_INSTANCES_TYPE_API = '/gpu-instance-types';
// View logs / events still go through the K8s proxy until the /v2
// /gpu-instances API exposes equivalents. clusterID and namespace come
// per-row from `row.clusterId` and `row.status.namespace`.
export const GPU_SERVICE_INSTANCES_LOG_API = (params: {
namespace: string;
name: string;
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/log`;
};
}) =>
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/log`;
export const GPU_SERVICE_INSTANCES_EVENTS_API = (params: {
namespace: string;
name: string;
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/events`;
};
}) =>
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/events`;
export const GPU_SERVICE_INSTANCE_PV_EVENTS_API = (params: {
namespace: string;
name: string;
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instancepersistentvolumes/${params.name}/events`;
};
// TODO: replace placeholder once start/stop endpoints are finalized
export const GPU_SERVICE_INSTANCE_START_API = (params: {
namespace: string;
name: string;
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/start`;
};
// TODO: replace placeholder once start/stop endpoints are finalized
export const GPU_SERVICE_INSTANCE_STOP_API = (params: {
namespace: string;
name: string;
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/stop`;
};
}) =>
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instancepersistentvolumes/${params.name}/events`;
// =========== Instances ===========
export async function queryGPUServiceInstances(
params: Global.K8sSearchParams & {
namespace: string;
clusterID?: number;
},
params: Global.SearchParams,
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<ListItem>>(
GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
clusterID: params.clusterID
}),
{
method: 'GET',
params: omitPathParams(params),
cancelToken: options?.token
}
);
return request<Global.PageResponse<ListItem>>(GPU_SERVICE_INSTANCES_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
export async function createGPUServiceInstance(
params: {
namespace: string;
clusterID?: number;
data: Global.K8sCommonData;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
clusterID: params.clusterID
}),
{
method: 'POST',
data: params.data,
cancelToken: option?.token
}
);
export async function createGPUServiceInstance(params: { data: FormData }) {
return request<ListItem>(GPU_SERVICE_INSTANCES_API, {
method: 'POST',
data: params.data
});
}
export async function updateGPUServiceInstance(
params: {
namespace: string;
clusterID?: number;
id: number;
data: Global.K8sCommonData;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
`${GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
clusterID: params.clusterID
})}/${params.data?.metadata?.name}`,
{
method: 'PUT',
data: params.data,
cancelToken: option?.token
}
);
export async function updateGPUServiceInstance(params: {
id: number;
data: Partial<FormData>;
}) {
return request<ListItem>(`${GPU_SERVICE_INSTANCES_API}/${params.id}`, {
method: 'PUT',
data: params.data
});
}
export async function deleteGPUServiceInstance(
params: {
namespace: string;
clusterID?: number;
id: number;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request(
`${GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
clusterID: params.clusterID
})}/${params.id}`,
{
method: 'DELETE',
cancelToken: option?.token
}
);
export async function deleteGPUServiceInstance(id: number) {
return request(`${GPU_SERVICE_INSTANCES_API}/${id}`, {
method: 'DELETE'
});
}
// =========== Instance Types ===========
export async function queryGPUServiceInstanceTypes(
params: Global.K8sSearchParams & { clusterID?: number },
params: Global.SearchParams = { page: 1, perPage: 100 },
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<InstanceTypeItem>>(
GPU_SERVICE_INSTANCES_TYPE_API({ clusterID: params.clusterID }),
return request<Global.PageResponse<InstanceTypeItem>>(
GPU_SERVICE_INSTANCES_TYPE_API,
{
method: 'GET',
params: omitPathParams(params),
params,
cancelToken: options?.token
}
);
}
// =========== Logs / Events (K8s proxy) ===========
export async function queryGPUServiceInstanceEvents(
params: {
namespace: string;
@@ -192,11 +106,11 @@ export async function queryGPUServiceInstanceEvents(
return;
}
return request<InstanceEvents>(
`${GPU_SERVICE_INSTANCES_EVENTS_API({
GPU_SERVICE_INSTANCES_EVENTS_API({
namespace: params.namespace,
clusterID: params.clusterID,
name: params.name
})}`,
}),
{
method: 'GET',
params: omitPathParams(_.omit(params, ['name'])),
@@ -217,11 +131,11 @@ export async function queryGPUServiceInstancePVEvents(
return;
}
return request<InstanceEvents>(
`${GPU_SERVICE_INSTANCE_PV_EVENTS_API({
GPU_SERVICE_INSTANCE_PV_EVENTS_API({
namespace: params.namespace,
clusterID: params.clusterID,
name: params.name
})}`,
}),
{
method: 'GET',
params: omitPathParams(_.omit(params, ['name'])),
@@ -230,56 +144,6 @@ export async function queryGPUServiceInstancePVEvents(
);
}
// TODO: replace placeholder once start/stop endpoints are finalized
export async function startGPUServiceInstance(
params: {
namespace: string;
name: string;
clusterID?: number;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request(
GPU_SERVICE_INSTANCE_START_API({
namespace: params.namespace,
clusterID: params.clusterID,
name: params.name
}),
{
method: 'POST',
cancelToken: option?.token
}
);
}
// TODO: replace placeholder once start/stop endpoints are finalized
export async function stopGPUServiceInstance(
params: {
namespace: string;
name: string;
clusterID?: number;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request(
GPU_SERVICE_INSTANCE_STOP_API({
namespace: params.namespace,
clusterID: params.clusterID,
name: params.name
}),
{
method: 'POST',
cancelToken: option?.token
}
);
}
export async function queryGPUServiceInstanceLog(
params: InstanceLogQueryParams & {
namespace: string;
@@ -292,11 +156,11 @@ export async function queryGPUServiceInstanceLog(
return;
}
return request<InstanceLog>(
`${GPU_SERVICE_INSTANCES_LOG_API({
GPU_SERVICE_INSTANCES_LOG_API({
namespace: params.namespace,
clusterID: params.clusterID,
name: params.name
})}`,
}),
{
method: 'GET',
params: omitPathParams(_.omit(params, ['name'])),
@@ -304,23 +168,3 @@ export async function queryGPUServiceInstanceLog(
}
);
}
export async function queryGPUServiceInstanceTypeItems(
params: {
name: string;
clusterID?: number;
},
options?: any
) {
if (!params.clusterID) {
return;
}
return request<InstanceTypeItem>(
`${GPU_SERVICE_INSTANCES_TYPE_API({ clusterID: params.clusterID })}/${params.name}`,
{
method: 'GET',
params: omitPathParams(params),
cancelToken: options?.token
}
);
}
@@ -101,7 +101,8 @@ const AddModal: React.FC<AddModalProps> = ({
const instanceTypeList = detailData?.items || [];
const templateList = templatesData?.items || [];
const readonly = action === PageAction.VIEW;
// const readonly = action === PageAction.VIEW;
const readonly = false;
const isRecreate = realAction === PageAction.CREATE;
const showResourceSelectors = action === PageAction.CREATE || isRecreate;
const shouldAutoSelectResource = action === PageAction.CREATE && !isRecreate;
@@ -119,27 +120,33 @@ const AddModal: React.FC<AddModalProps> = ({
instanceType: InstanceTypeItem,
template: TemplateItem | undefined
) => {
const name = instanceType.metadata?.name;
const manufacturer = instanceType.spec?.manufacturer;
setInstanceTypeSelection({ instanceType: name, manufacturer });
setInstanceTypeSelection({
instanceType: instanceType.name,
manufacturer
});
setTemplateId(template?.id);
const currentSpec = form.current?.getFieldsValue()?.spec || {};
const updatedSpec = {
...currentSpec,
type: name,
resources: { ...(currentSpec.resources || {}), accelerator: '1' }
};
if (template) {
const currentSpec = form.current?.getFieldsValue()?.spec || {};
form.current?.setFieldsValue({
manufacturer: template.manufacturer,
description: JSON.stringify(
{
name: instanceType.name,
spec: {
...instanceType.spec
}
},
null,
2
),
spec: {
...updatedSpec,
...currentSpec,
...template.spec,
resources: {
...(updatedSpec.resources || {}),
...(currentSpec.resources || {}),
...(template.spec?.resources || {})
}
}
@@ -147,16 +154,46 @@ const AddModal: React.FC<AddModalProps> = ({
} else {
form.current?.setFieldsValue({
manufacturer: undefined,
spec: updatedSpec
description: JSON.stringify(instanceType.spec, null, 2)
});
}
form.current?.applyInstanceType?.(instanceType);
};
const findAggregateOf = (
candidateName: string | undefined,
clusterId: number | null | undefined,
instanceTypes: InstanceTypeItem[]
): InstanceTypeItem | undefined => {
if (!candidateName) return undefined;
return instanceTypes.find((item) =>
(item.status?.acceleratorTiers ?? []).some((tier) =>
(tier.candidates ?? []).some(
(c) => c.name === candidateName && Number(c.cluster) === clusterId
)
)
);
};
const applyAutoSelection = (
instanceTypes: InstanceTypeItem[],
templates: TemplateItem[]
) => {
if (!shouldAutoSelectResource) return;
// On edit / view, surface the persisted selection in the card list.
if (!shouldAutoSelectResource) {
const aggregate = findAggregateOf(
data?.spec?.type,
data?.clusterId,
instanceTypes
);
if (aggregate) {
setInstanceTypeSelection({
instanceType: aggregate.name,
manufacturer: aggregate.spec?.manufacturer
});
}
return;
}
if (!instanceTypes.length) return;
const first = instanceTypes[0];
@@ -178,18 +215,21 @@ const AddModal: React.FC<AddModalProps> = ({
return;
}
const session = ++sessionRef.current;
Promise.all([fetchData({}), fetchTemplates({ page: -1 })]).then(
([instanceRes, templatesRes]) => {
if (action === PageAction.CREATE) {
const session = ++sessionRef.current;
Promise.all([
fetchData({ page: 1, perPage: 100 }),
fetchTemplates({ page: -1 })
]).then(([instanceRes, templatesRes]) => {
if (sessionRef.current !== session) return;
applyAutoSelection(instanceRes?.items || [], templatesRes?.items || []);
}
);
}, [open, shouldAutoSelectResource]);
});
}
}, [open, shouldAutoSelectResource, action]);
// filter instance types
const filteredInstanceTypes = instanceTypeList.filter((item) =>
matchKeyword([item.metadata?.name], instanceKeyword)
matchKeyword([item.name], instanceKeyword)
);
// filter templates based on selection and keyword
@@ -370,7 +410,7 @@ const AddModal: React.FC<AddModalProps> = ({
}
>
<>
{action !== PageAction.VIEW && (
{action !== PageAction.EDIT && (
<ColTitle>
{intl.formatMessage({ id: 'common.title.config' })}
</ColTitle>
@@ -1,16 +1,12 @@
import { AutoTooltip, IconFont, StatusTag, ThemeTag } from '@gpustack/core-ui';
import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import styled from 'styled-components';
import { manufactureColorMap } from '../../templates/config';
import {
convertKiToGi,
InstanceTypePhaseLabelMap,
InstanceTypePhaseStatus
} from '../config';
import { convertKiToGi } from '../config';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
const toDisplayUnit = (value?: string) =>
const toDisplayUnit = (value?: string | null) =>
value ? value.replace(/Gi$/, 'GB').replace(/Ti$/, 'TB') : value;
const Title = styled.div`
@@ -60,24 +56,48 @@ interface InstanceTypeItemProps {
showStatus?: boolean;
}
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
item,
showStatus = true
}) => {
const intl = useIntl();
const name = item.metadata?.name;
const acceleratable = item.spec?.acceleratable;
const MetaItem: React.FC<{
icon: string;
label: string;
value?: string | null;
showDot?: boolean;
show?: boolean;
}> = ({ icon, label, value, showDot = true, show = true }) => {
if (!show) return null;
return (
<>
{showDot && <span className="dot"></span>}
<span className="meta-item">
<IconFont type={icon} className="meta-icon" />
<Flex align="center" gap={4}>
<span>{label}</span>
<span>{value || '-'}</span>
</Flex>
</span>
</>
);
};
const manufacturerKey = item.spec?.manufacturer;
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
const intl = useIntl();
const name = item.name;
const specData = item.spec || {};
const acceleratable = specData.acceleratable;
const manufacturerKey = specData.manufacturer || (acceleratable ? '' : 'cpu');
const manufacturer = manufacturerKey?.toUpperCase();
const manufacturerColor =
(manufacturerKey && manufactureColorMap[manufacturerKey]) ?? 'purple';
// resource remaining status
const remainingData = item.status?.remaining || {};
const renderName = () => {
const product = item.spec?.product;
const product = specData.product;
const cpuCapacity = remainingData.cpu;
const displayName =
product ||
(!acceleratable ? `${item.status?.cpu?.capacity} vCPUs` : name);
(!acceleratable && cpuCapacity ? `${cpuCapacity} vCPUs` : name);
return displayName;
};
@@ -85,8 +105,8 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
<>
<Title>
<Flex gap={8} align="center">
<AutoTooltip ghost minWidth={20} maxWidth={180}>
{item.spec ? renderName() : '-'}
<AutoTooltip ghost minWidth={20} maxWidth={200}>
{renderName() || '-'}
</AutoTooltip>
{acceleratable && manufacturer && (
<span
@@ -95,99 +115,59 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
fontWeight: 400
}}
>
<ThemeTag color={manufacturerColor}>{manufacturer}</ThemeTag>
<ThemeTag color={manufacturerColor} disabled={false}>
{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>
{toDisplayUnit(convertKiToGi(item.spec?.memory)) ?? '-'}
</span>
</Flex>
</span>
{item.spec?.sliced && (
<>
<span className="dot"></span>
<span className="meta-item">
<IconFont type="icon-sliced" className="meta-icon" />
<Flex align="center" gap={4}>
<span>
{intl.formatMessage({
id: 'gpuservice.instance.sliced'
})}
</span>
<span>{item.spec?.sliced ?? '-'}</span>
</Flex>
</span>
</>
)}
{item.status?.accelerator?.remaining && (
<>
<span className="dot"></span>
<span className="meta-item">
<IconFont type="icon-database" className="meta-icon" />
<Flex align="center" gap={4}>
<span>
{intl.formatMessage({
id: 'gpuservice.instance.stock'
})}
</span>
<span>{item.status?.accelerator?.remaining ?? '-'}</span>
</Flex>
</span>
</>
)}
<MetaItem
showDot={false}
icon="icon-gpu1"
label={intl.formatMessage({ id: 'gpuservice.instance.memory' })}
value={
toDisplayUnit(convertKiToGi(specData?.memory ?? undefined)) ??
'-'
}
></MetaItem>
<MetaItem
show={!!specData?.sliced}
icon="icon-sliced"
label={intl.formatMessage({
id: 'gpuservice.instance.sliced'
})}
value={specData?.sliced}
></MetaItem>
<MetaItem
show={!!remainingData.accelerator}
icon="icon-database"
label={intl.formatMessage({
id: 'gpuservice.instance.stock'
})}
value={remainingData.accelerator}
></MetaItem>
</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>{toDisplayUnit(item.status?.ram?.capacity) ?? '-'}</span>
</Flex>
</span>
{acceleratable && (
<>
<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>
</>
)}
<MetaItem
showDot={false}
icon="icon-ram-02"
label={intl.formatMessage({ id: 'gpuservice.instance.ram' })}
value={toDisplayUnit(convertKiToGi(remainingData.ram)) ?? '-'}
></MetaItem>
<MetaItem
show={acceleratable}
showDot={true}
icon="icon-cpu"
label="vCPU"
value={remainingData.cpu ?? '-'}
></MetaItem>
</span>
</Meta>
</>
@@ -3,7 +3,7 @@ import { TemplateCard } from '@gpustack/core-ui';
import { Empty, Spin } from 'antd';
import _ from 'lodash';
import styled from 'styled-components';
import { InstanceTypePhaseValueMap } from '../config';
import { getAcceleratorMax } from '../config';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
import InstanceTypeItem from './instance-type-item';
@@ -20,8 +20,12 @@ interface InstanceTypeListProps {
loading?: boolean;
}
const isAvailable = (item: InstanceTypeItemModel) =>
item.status?.phase === InstanceTypePhaseValueMap.Active;
// CPU types always have stock (no accelerator). Accelerated types are
// considered available when at least one tier offers onceMaxRequest > 0.
const isAvailable = (item: InstanceTypeItemModel) => {
if (!item.spec?.acceleratable) return true;
return getAcceleratorMax(item.status?.acceleratorTiers) > 0;
};
const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
value,
@@ -53,7 +57,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
<TypeGrid>
{dataList.map((item) => {
const disabled = !isAvailable(item);
const name = item.metadata?.name;
const name = item.name;
return (
<TemplateCard
key={name}
@@ -31,7 +31,7 @@ const eventTypeStatus: Record<string, 'success' | 'warning' | 'error'> = {
const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
const intl = useIntl();
const { open, onCancel, name } = props || {};
const { open, onCancel, name, namespace, clusterID } = props || {};
const [activeKey, setActiveKey] = useState('instance');
const {
@@ -52,9 +52,9 @@ const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
const volumeEvents = volumeEventsData?.items ?? [];
const refreshAll = () => {
if (!name) return;
fetchInstanceEvents({ name });
fetchVolumeEvents({ name });
if (!name || !namespace || !clusterID) return;
fetchInstanceEvents({ name, namespace, clusterID });
fetchVolumeEvents({ name, namespace, clusterID });
};
useEffect(() => {
@@ -0,0 +1,14 @@
import { PageActionType } from '@/config/types';
import { createContext } from 'react';
export interface FormContextValue {
action: PageActionType;
currentData: any;
}
export const FormContext = createContext<FormContextValue>({
action: 'create',
currentData: null
});
export const FormContextProvider = FormContext.Provider;
+79 -78
View File
@@ -3,11 +3,6 @@ import { StatusType } from '@/config/types';
import { IconFont, icons } from '@gpustack/core-ui';
import _ from 'lodash';
import React from 'react';
import {
InstanceTypeItem,
InstanceTypeResource,
InstanceTypeStatus
} from './types';
export const InstanceStatusValueMap = {
Scheduling: 'Scheduling',
@@ -45,13 +40,22 @@ export const status: Record<string, StatusType> = {
[InstanceStatusValueMap.Ready]: StatusMaps.success
};
// Lifecycle actions (logs/events/start/stop) require K8s proxy endpoints that
// the new /v2/gpu-instances API does not yet expose; keep them visible but
// disabled until backend support lands.
export const rowActionList = [
{
label: 'common.button.view',
key: 'view',
label: 'common.button.edit',
key: 'edit',
locale: true,
icon: icons.DetailInfo
icon: icons.EditOutlined
},
// {
// label: 'common.button.view',
// key: 'view',
// locale: true,
// icon: icons.DetailInfo
// },
{
label: 'common.button.viewlog',
key: 'viewlog',
@@ -64,30 +68,30 @@ export const rowActionList = [
locale: true,
icon: icons.ProfileOutlined
},
{
label: 'common.button.start',
key: 'start',
locale: true,
icon: icons.Play
},
{
label: 'common.button.stop',
key: 'stop',
locale: true,
icon: icons.Stop
},
// {
// label: 'common.button.edit',
// key: 'edit',
// label: 'common.button.start',
// key: 'start',
// locale: true,
// icon: icons.EditOutlined
// icon: icons.Play,
// props: {
// disabled: true
// }
// },
// {
// label: 'common.button.stop',
// key: 'stop',
// locale: true,
// icon: icons.Stop,
// props: {
// disabled: true
// }
// },
// {
// label: 'common.button.recreate',
// key: 'recreate',
// locale: true,
// icon: icons.RetweetOutlined
// },
{
label: 'common.button.recreate',
key: 'recreate',
locale: true,
icon: icons.RetweetOutlined
},
{
label: 'common.button.delete',
key: 'delete',
@@ -103,12 +107,18 @@ export const batchActionList = [
{
label: 'common.button.start',
key: 'start',
icon: icons.Play
icon: icons.Play,
props: {
disabled: true
}
},
{
label: 'common.button.stop',
key: 'stop',
icon: icons.Stop
icon: icons.Stop,
props: {
disabled: true
}
},
{
label: 'common.button.delete',
@@ -121,8 +131,6 @@ export const batchActionList = [
];
export const InstanceTypePhaseValueMap = {
// Available: 'Available',
// Unavailable: 'Unavailable'
PreParing: 'Preparing',
Inactive: 'Inactive',
Active: 'Active'
@@ -141,10 +149,12 @@ export const InstanceTypePhaseStatus: Record<string, StatusType> = {
};
export const StorageModeValueMap = {
Existing: 'existing',
Temporary: 'temporary'
Temporary: 'temporary',
Persistent: 'persistent'
};
export const DEFAULT_PV_CAPACITY_GB = 20;
// Constant SSH public key resource name used when SSH is enabled
export const DEFAULT_SSH_PUBLIC_KEY_NAME = 'default';
@@ -163,49 +173,40 @@ export const convertKiToGi = (value?: string): string | undefined => {
return `${_.round(Number(num) / GI_DIVISOR[unit], 2)}Gi`;
};
export const transformInstanceTypeResource = (
resource?: InstanceTypeResource
): InstanceTypeResource | undefined => {
if (!resource) return resource;
return {
...resource,
capacity: convertKiToGi(
resource.capacity
) as InstanceTypeResource['capacity'],
onceMaxRequest: convertKiToGi(
resource.onceMaxRequest
) as InstanceTypeResource['onceMaxRequest'],
remaining: convertKiToGi(
resource.remaining
) as InstanceTypeResource['remaining']
};
const parseQuantity = (value?: string | null): number => {
if (!value) return 0;
const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value));
return match ? Number(match[1]) || 0 : 0;
};
export const transformInstanceType = (
item: InstanceTypeItem
): InstanceTypeItem => {
if (!item?.status) return item;
const status = item.status;
return {
...item,
spec: {
...item.spec,
manufacturer: !item.spec?.acceleratable ? 'cpu' : item.spec.manufacturer
},
status: {
...status,
accelerator: transformInstanceTypeResource(
status.accelerator
) as InstanceTypeStatus['accelerator'],
cpu: transformInstanceTypeResource(
status.cpu
) as InstanceTypeStatus['cpu'],
localStorage: transformInstanceTypeResource(
status.localStorage
) as InstanceTypeStatus['localStorage'],
ram: transformInstanceTypeResource(
status.ram
) as InstanceTypeStatus['ram']
}
};
// Returns the slider max for the accelerator count: the largest
// tier.onceMaxRequest across all acceleratorTiers (not from candidates).
export const getAcceleratorMax = (
tiers?: { onceMaxRequest: string }[] | null
) => {
if (!tiers?.length) return 0;
return tiers.reduce((acc, tier) => {
const n = parseQuantity(tier.onceMaxRequest);
return n > acc ? n : acc;
}, 0);
};
// Picks the candidate (cluster + type name) that should fulfill a requested
// accelerator count: the first candidate of the smallest tier whose
// onceMaxRequest is >= the requested count.
export const pickCandidateForAccelerator = <
C extends { cluster: string; name: string }
>(
tiers:
| { onceMaxRequest: string; candidates?: C[] | null }[]
| undefined
| null,
count: number
): C | null => {
if (!tiers?.length) return null;
const sorted = [...tiers].sort(
(a, b) => parseQuantity(a.onceMaxRequest) - parseQuantity(b.onceMaxRequest)
);
const tier = sorted.find((t) => parseQuantity(t.onceMaxRequest) >= count);
return tier?.candidates?.[0] ?? null;
};
+90 -116
View File
@@ -2,16 +2,16 @@ export type ImagePullPolicy = 'Always' | 'IfNotPresent' | 'Never';
// instance form data
export interface FormData {
metadata: {
name: string;
namespace: string;
};
name: string;
clusterId?: number | null;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
enable_ssh?: boolean;
spec: {
type: string;
image: string;
imagePullPolicy: ImagePullPolicy;
displayName: string;
command: string[];
ports: {
port: number;
@@ -27,9 +27,11 @@ export interface FormData {
cpu?: string;
ram?: string;
localStorage?: string;
accelerator?: string;
// Stored in the form as a number so NumberSelection's strict
// equality picks up the active item. Serialized to a string at the
// API boundary in handleFinish.
accelerator?: number | string;
};
description: string;
volume: {
ephemeral?: {
capacity?: string;
@@ -37,162 +39,136 @@ export interface FormData {
persistent?: {
name: string;
};
persistentTemplate?: {
name?: string;
spec: {
type: string;
capacity: string;
};
releaseWithInstance?: boolean;
};
};
sshPublicKey: {
name: string;
};
sshPublicKeys?: { name: string }[];
};
}
export type InstanceServicePortProtocol = 'TCP' | 'UDP';
export interface InstanceIP {
ip: string;
}
export interface InstanceServicePort {
port: number;
name: string;
nodePort?: number;
protocol?: InstanceServicePortProtocol;
}
export interface InstanceStatus {
hostIPs?: {
ip: string;
}[];
namespace?: string;
phase?: string;
phaseMessage?: string;
podIPs?: {
ip: string;
}[];
ports?: {
port: number;
name: string;
nodePort?: number;
protocol?: InstanceServicePortProtocol;
}[];
hostIPs?: InstanceIP[];
podIPs?: InstanceIP[];
ports?: InstanceServicePort[];
}
// instance list item
export interface ListItem extends Omit<FormData, 'metadata'> {
export interface ListItem extends FormData {
id: number;
status?: InstanceStatus;
metadata: {
name: string;
namespace?: string;
uid: string;
resourceVersion: string;
creationTimestamp: string;
annotations?: Record<string, string>;
managedFields?: {
manager: string;
operation: string;
apiVersion: string;
time: string;
fieldsType: string;
fieldsV1: Record<string, any>;
}[];
};
created_at: string;
updated_at: string;
deleted_at?: string | null;
status?: InstanceStatus | null;
}
type Quality =
| `${number}`
| `${number}Ki`
| `${number}Mi`
| `${number}Gi`
| `${number}Ti`;
// =========== Instance Types ===========
export interface InstanceTypeResource {
capacity?: Quality;
onceMaxRequest?: Quality;
remaining?: Quality;
onceMaxRequest: string;
remaining: string;
capacity: string;
}
export interface InstanceTypeStatus {
export interface InstanceTypeCandidate {
cluster: string;
name: string;
accelerator: InstanceTypeResource;
cpu: InstanceTypeResource;
localStorage: InstanceTypeResource;
phase: string;
phaseMessage?: string;
ram: InstanceTypeResource;
localStorage: InstanceTypeResource;
}
export interface InstanceTypeMetadata {
name: string;
namespace?: string;
uid?: string;
resourceVersion?: string;
creationTimestamp?: string;
generateName?: string;
generation?: number;
finalizers?: string[];
export interface InstanceTypeTier {
onceMaxRequest: string;
candidates?: InstanceTypeCandidate[] | null;
}
export interface InstanceTypeRemainingResource {
accelerator?: string | null;
cpu: string;
ram: string;
localStorage: string;
}
export interface InstanceTypeSpec {
acceleratable: boolean;
group: string;
computeCapability?: string;
family?: string;
manufacturer?: string;
memory?: string;
product?: string;
sliced?: number;
acceleratable: boolean;
manufacturer: string;
product?: string | null;
memory?: string | null;
family?: string | null;
computeCapability?: string | null;
sliced?: string | null;
}
export interface InstanceTypeStatus {
remaining: InstanceTypeRemainingResource;
acceleratorTiers?: InstanceTypeTier[] | null;
}
export interface InstanceTypeItem {
apiVersion: string;
kind: string;
id: number;
metadata: InstanceTypeMetadata;
name: string;
spec: InstanceTypeSpec;
status: InstanceTypeStatus;
}
export interface InstanceEventObjectReference {
apiVersion?: string;
fieldPath?: string;
kind?: string;
name?: string;
namespace?: string;
resourceVersion?: string;
uid?: string;
}
export interface InstanceEventSource {
component?: string;
host?: string;
}
export interface InstanceEventSeries {
count?: number;
lastObservedTime?: string;
}
// =========== Events / logs (placeholder) ===========
// The /v2/gpu-instances API does not yet expose log/event endpoints. These
// shapes are retained as minimal placeholders so disabled UI continues to
// compile until backend support lands.
export interface InstanceEventItem {
action?: string;
apiVersion?: string;
type?: string;
reason?: string;
message?: string;
count?: number;
eventTime?: string;
firstTimestamp?: string;
involvedObject: InstanceEventObjectReference;
kind?: string;
lastTimestamp?: string;
message?: string;
metadata: {
name?: string;
namespace?: string;
uid?: string;
resourceVersion?: string;
creationTimestamp?: string;
};
reason?: string;
related?: InstanceEventObjectReference;
reportingComponent?: string;
reportingInstance?: string;
series?: InstanceEventSeries;
source?: InstanceEventSource;
type?: string;
source?: {
component?: string;
host?: string;
};
series?: {
count?: number;
lastObservedTime?: string;
};
metadata?: {
name?: string;
namespace?: string;
};
}
export interface InstanceEvents {
apiVersion?: string;
kind?: string;
metadata?: {
resourceVersion?: string;
};
items: InstanceEventItem[];
}
export type InstanceLog = string;
export interface InstanceLogQueryParams {
follow?: boolean;
limitBytes?: number;
@@ -201,5 +177,3 @@ export interface InstanceLogQueryParams {
timestamps?: boolean;
pretty?: string;
}
export type InstanceLog = string;
@@ -3,6 +3,7 @@ import { PageActionType } from '@/config/types';
import { Input as CInput } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormData } from '../config/types';
import formStyles from '../styles/instances.module.less';
@@ -16,9 +17,10 @@ const Basic = ({
const intl = useIntl();
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
data-field="name"
name={['metadata', 'name']}
name="name"
rules={[
{
required: true,
@@ -38,10 +40,17 @@ const Basic = ({
required
/>
</Form.Item>
<Form.Item<FormData> name="displayName">
<CInput.Input
trim={false}
disabled={false}
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<div className={formStyles.command}>
<Form.Item<FormData> name={['spec', 'description']}>
<Form.Item<FormData> name="description" hidden>
<CInput.TextArea
disabled={disabled}
disabled={false}
label={intl.formatMessage({ id: 'common.table.description' })}
scaleSize
/>
+188 -53
View File
@@ -4,6 +4,7 @@ import { PageActionType } from '@/config/types';
import { PlusOutlined } from '@ant-design/icons';
import {
CheckboxField,
Input as CInput,
CollapsePanel,
IconFont,
MultipleSelect,
@@ -21,13 +22,15 @@ import {
useEffect,
useImperativeHandle,
useMemo,
useRef
useRef,
useState
} from 'react';
import useGetSshkey from '../../public-keys/services/use-get-sshkey';
import useQuerySshkeys from '../../public-keys/services/use-query-sshkeys';
import { DefaultImagePullPolicy } from '../../templates/config';
import TemplateBasicForm, {
BasicResourceMax
} from '../../templates/forms/basic';
import { pickCandidateForAccelerator } from '../config';
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
import instanceStyles from '../styles/instances.module.less';
import Basic from './basic';
@@ -53,7 +56,7 @@ interface InstanceFormProps {
onFinish: (values: FormData) => Promise<void>;
}
const parseQuantityToNumber = (value?: string): number | null => {
const parseQuantityToNumber = (value?: string | null): number | null => {
if (!value) return null;
const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value));
if (!match) return null;
@@ -61,6 +64,27 @@ const parseQuantityToNumber = (value?: string): number | null => {
return Number.isFinite(num) && num > 0 ? num : null;
};
// Converts a K8s-style quantity ("24314504Ki", "2Ti", "10Gi", or a bare
// number assumed to be Gi) to an integer GiB. memory and localStorage
// inputs are displayed in GB, so the max bound and the "Remaining {count}
// GB" label need the converted value.
const GI_FROM_UNIT: Record<string, number> = {
Ki: 1 / (1024 * 1024),
Mi: 1 / 1024,
Gi: 1,
Ti: 1024
};
const parseQuantityToGi = (value?: string | null): number | null => {
if (!value) return null;
const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/.exec(String(value));
if (!match) return null;
const num = Number(match[1]);
if (!Number.isFinite(num) || num <= 0) return null;
const factor = match[2] ? GI_FROM_UNIT[match[2]] : 1;
return Math.floor(num * factor);
};
const TABKeysMap = {
BASIC: 'basic',
INSTANCE_TYPE: 'instanceType',
@@ -71,7 +95,7 @@ const TABKeysMap = {
const requiredFields = {
[TABKeysMap.BASIC]: {
sort: 1,
fields: ['metadata.name']
fields: ['name']
},
[TABKeysMap.INSTANCE_TYPE]: {
sort: 2,
@@ -95,7 +119,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
currentData,
disabled,
open,
namespace = 'default',
instanceTypeList = [],
onFinish
} = props;
@@ -108,7 +131,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
const formAction =
realAction === PageAction.CREATE ? PageAction.CREATE : action;
const sshEnabled = Form.useWatch('enable_ssh', form);
const { detailData: sshKeyData, fetchData: fetchSSHData } = useGetSshkey();
const { sshkeyOptions, fetchData: fetchSSHData } = useQuerySshkeys();
const { getScrollElementScrollableHeight } = useWrapperContext();
const {
activeKey,
@@ -127,25 +150,25 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
useEffect(() => {
if (open) {
fetchSSHData({});
fetchSSHData({ page: 1, perPage: 100 });
}
}, [open, action]);
const handleSSHEnableChange = (e: any) => {
if (!e?.target?.checked) {
return;
const ports = Form.useWatch(['spec', 'ports'], form) || [];
const hasSSHPort = useMemo(
() =>
ports.some(
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
),
[ports]
);
useEffect(() => {
if (hasSSHPort && !form.getFieldValue('enable_ssh')) {
form.setFieldValue('enable_ssh', true);
}
const currentPorts = form.getFieldValue(['spec', 'ports']) || [];
const hasSSHPort = currentPorts.some(
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
);
if (!hasSSHPort) {
form.setFieldValue(
['spec', 'ports'],
[...currentPorts, { protocol: 'TCP', port: SSH_PORT, name: 'SSH' }]
);
}
};
}, [hasSSHPort, form]);
const segmentOptions = useMemo(
() => [
@@ -183,25 +206,50 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
[intl]
);
const ports = Form.useWatch(['spec', 'ports'], form) || [];
const [selectedInstanceType, setSelectedInstanceType] = useState<
InstanceTypeItem | undefined
>(undefined);
const [onceMaxRequest, setOnceMaxRequest] = useState<BasicResourceMax>({
cpu: null,
memory: null,
localStorage: null
});
const selectedTypeName = Form.useWatch(['spec', 'type'], form) as
| string
| undefined;
const onceMaxRequest = useMemo<BasicResourceMax>(() => {
const selected = instanceTypeList.find(
(item) => (item.metadata?.name || '') === selectedTypeName
const resolveAndApply = (
instanceType: InstanceTypeItem | undefined,
count: number,
options: { writeAccelerator?: boolean } = {}
) => {
if (!instanceType) {
setSelectedInstanceType(undefined);
setOnceMaxRequest({ cpu: null, memory: null, localStorage: null });
return;
}
const candidate = pickCandidateForAccelerator(
instanceType.status?.acceleratorTiers,
count
);
const status = selected?.status;
return {
cpu: parseQuantityToNumber(status?.cpu?.onceMaxRequest),
memory: parseQuantityToNumber(status?.ram?.onceMaxRequest),
localStorage: parseQuantityToNumber(
status?.localStorage?.onceMaxRequest
)
};
}, [instanceTypeList, selectedTypeName]);
setSelectedInstanceType(instanceType);
setOnceMaxRequest({
cpu: parseQuantityToNumber(candidate?.cpu?.onceMaxRequest),
memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest),
localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest)
});
form.setFieldsValue({
clusterId: candidate?.cluster ? Number(candidate.cluster) : null,
spec: {
type: candidate?.name || '',
...(options.writeAccelerator
? { resources: { accelerator: count } }
: {})
} as any
});
};
const handleAcceleratorChange = (count: number) => {
resolveAndApply(selectedInstanceType, count);
};
const onTargetChange = (key: string) => {
scrollTabsRef.current?.handleTargetChange(key);
@@ -235,34 +283,110 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
action === PageAction.VIEW ||
realAction === PageAction.CREATE)
) {
// The server returns `accelerator` as a string; coerce to a number
// so NumberSelection's strict equality picks up the active item.
const persistedAccelerator = currentData.spec?.resources?.accelerator;
const acceleratorAsNumber =
persistedAccelerator != null && persistedAccelerator !== ''
? Number(persistedAccelerator)
: undefined;
form.setFieldsValue({
metadata: {
name: currentData.metadata?.name,
namespace: currentData.metadata?.namespace
},
name: currentData.name,
displayName: currentData.displayName,
description: currentData.description,
clusterId: currentData.clusterId,
spec: {
...currentData.spec,
imagePullPolicy:
currentData.spec?.imagePullPolicy || DefaultImagePullPolicy
},
enable_ssh: !!currentData.spec?.sshPublicKey?.name
currentData.spec?.imagePullPolicy || DefaultImagePullPolicy,
resources: {
...currentData.spec?.resources,
accelerator: acceleratorAsNumber
},
sshPublicKeys:
currentData.spec?.sshPublicKeys?.map((k) => k.name) ?? []
} as any,
enable_ssh: !!currentData.spec?.sshPublicKeys?.length
});
const candidateName = currentData.spec?.type;
const clusterId = currentData.clusterId;
const aggregate = instanceTypeList.find((item) =>
(item.status?.acceleratorTiers ?? []).some((tier) =>
(tier.candidates ?? []).some(
(c) => c.name === candidateName && Number(c.cluster) === clusterId
)
)
);
if (aggregate) {
const count =
Number(currentData.spec?.resources?.accelerator) ||
(aggregate.spec?.acceleratable ? 1 : 0);
// No accelerator rewrite — the form already has the persisted value.
resolveAndApply(aggregate, count);
}
return;
}
}, [action, currentData, form, open, namespace, realAction]);
}, [action, currentData, form, open, realAction, instanceTypeList]);
const handleFinish = async (values: InstanceFormValues) => {
const selectedKeys = (values.spec as any)?.sshPublicKeys as
| string[]
| undefined;
const volume = values.spec?.volume ?? {};
const normalizedVolume = volume.persistentTemplate
? {
persistentTemplate: {
...volume.persistentTemplate,
name: volume.persistentTemplate.name || values.name || ''
}
}
: volume.persistent
? { persistent: volume.persistent }
: volume.ephemeral
? { ephemeral: volume.ephemeral }
: {};
// `spec.type` and `clusterId` are kept in sync with the resolved
// candidate via `resolveAndApply`, so submission is a straight pass
// -through. The API expects `accelerator` as a string per the schema.
const acceleratorValue = values.spec?.resources?.accelerator;
const normalizedResources = {
...values.spec?.resources,
accelerator:
acceleratorValue != null && acceleratorValue !== ''
? String(acceleratorValue)
: undefined
};
const submittedPorts = [...(values.spec?.ports ?? [])];
const submittedHasSSHPort = submittedPorts.some(
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
);
if (values.enable_ssh && !submittedHasSSHPort) {
submittedPorts.push({
protocol: 'TCP',
port: SSH_PORT,
name: 'SSH'
});
}
await onFinish({
...values,
spec: {
...values.spec,
sshPublicKey: { name: sshKeyData?.name }
ports: submittedPorts,
resources: normalizedResources,
volume: normalizedVolume,
sshPublicKeys: values.enable_ssh
? (selectedKeys ?? []).map((name) => ({ name }))
: []
}
});
};
const segmentedTop =
action === PageAction.VIEW
action === PageAction.EDIT
? {
top: 0,
offsetTop: 100
@@ -282,7 +406,11 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
setFieldsValue: (values: Partial<InstanceFormValues>) => {
form.setFieldsValue(values as any);
},
getFieldsValue: () => form.getFieldsValue()
getFieldsValue: () => form.getFieldsValue(),
applyInstanceType: (instanceType: InstanceTypeItem) => {
const count = instanceType.spec?.acceleratable ? 1 : 0;
resolveAndApply(instanceType, count, { writeAccelerator: true });
}
}));
const handleAddSSHKey = () => {
@@ -319,7 +447,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
env: [],
volumeMount: '',
resources: {
accelerator: '1'
accelerator: 1
},
volume: {
ephemeral: {
@@ -334,6 +462,11 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}}
>
<Basic action={formAction} disabled={disabled} />
{/* Hidden form field: the candidate.cluster resolved from the
selected aggregate + accelerator count. */}
<Form.Item name="clusterId" hidden>
<CInput.Input />
</Form.Item>
<CollapsePanel
activeKey={collapseKeys}
accordion={false}
@@ -349,6 +482,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
<InstanceTypeFormItem
action={formAction}
disabled={disabled}
selectedInstanceType={selectedInstanceType}
currentData={currentData as any}
onGPUCountChange={handleAcceleratorChange}
/>
)
},
@@ -392,13 +528,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
label={intl.formatMessage({
id: 'gpuservice.instance.ssh.enable'
})}
onChange={handleSSHEnableChange}
></CheckboxField>
</Form.Item>
{sshEnabled && (
<div className={instanceStyles.sshkeySelection}>
<Form.Item<FormData>
name={['spec', 'sshPublicKey', 'name']}
name={['spec', 'sshPublicKeys']}
style={{
marginBottom: 12
}}
@@ -425,7 +560,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
width: '100%'
}
}}
options={[]}
options={sshkeyOptions}
notFoundContent={
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
@@ -2,16 +2,17 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import NumberSelection from '@/pages/_components/number-selection';
import { useIntl } from '@umijs/max';
import { Alert, Form } from 'antd';
import { useEffect, useMemo } from 'react';
import { Alert, Flex, Form } from 'antd';
import _ from 'lodash';
import { useMemo } from 'react';
import styled from 'styled-components';
import InstanceTypeItem from '../components/instance-type-item';
import { InstanceTypePhaseValueMap } from '../config';
import { getAcceleratorMax } from '../config';
import {
FormData,
InstanceTypeItem as InstanceTypeItemModel
InstanceTypeItem as InstanceTypeItemModel,
ListItem
} from '../config/types';
import useQueryInstanceTypes from '../services/use-query-instance-types';
const FieldBlock = styled.div`
margin-bottom: 24px;
@@ -25,24 +26,22 @@ const SelectedCard = styled.div`
`;
interface InstanceTypePickerProps {
value?: string;
dataList: InstanceTypeItemModel[];
selectedInstanceType?: InstanceTypeItemModel;
}
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
value,
dataList
selectedInstanceType
}) => {
const selected = useMemo(() => {
return (
dataList.find((item) => (item.metadata?.name || '') === value) ||
({} as InstanceTypeItemModel)
);
}, [value, dataList]);
const intl = useIntl();
return (
<SelectedCard>
<InstanceTypeItem item={selected} showStatus={false} />
{selectedInstanceType ? (
<InstanceTypeItem item={selectedInstanceType} showStatus={false} />
) : (
<span style={{ color: 'var(--ant-color-text-tertiary)' }}>
{intl.formatMessage({ id: 'gpuservice.instance.type.required' })}
</span>
)}
</SelectedCard>
);
};
@@ -50,56 +49,62 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
interface InstanceTypeFormItemProps {
action: PageActionType;
disabled?: boolean;
currentData?: ListItem;
selectedInstanceType?: InstanceTypeItemModel;
onGPUCountChange?: (value: number) => void;
}
const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
action,
disabled
disabled,
currentData,
selectedInstanceType,
onGPUCountChange
}) => {
const intl = useIntl();
const form = Form.useFormInstance<FormData>();
const typeName = Form.useWatch(['spec', 'type'], form) as string | undefined;
const { detailData, fetchData } = useQueryInstanceTypes();
const maxGpuCount =
action === PageAction.EDIT
? _.toNumber(currentData?.spec?.resources?.accelerator) || 0
: getAcceleratorMax(selectedInstanceType?.status?.acceleratorTiers);
useEffect(() => {
fetchData({});
}, [fetchData]);
const handleOnGPUCountChange = (value: number) => {
onGPUCountChange?.(value);
};
const dataList = detailData?.items || [];
const selectedInstanceType = useMemo(() => {
return dataList.find((item) => (item.metadata?.name || '') === typeName);
}, [dataList, typeName]);
const maxGpuCount = useMemo(() => {
const onceMaxRequest =
selectedInstanceType?.status?.accelerator?.onceMaxRequest;
const num = onceMaxRequest ? Number(onceMaxRequest) : 0;
return num;
}, [selectedInstanceType]);
useEffect(() => {
if (!typeName && dataList.length > 0) {
const defaultType = dataList.find(
(item) => item.status?.phase === InstanceTypePhaseValueMap.Active
);
if (defaultType) {
form.setFieldValue(['spec', 'type'], defaultType.metadata?.name || '');
}
return;
const showGPUCount = useMemo(() => {
if (action === PageAction.EDIT) {
return _.toNumber(currentData?.spec?.resources?.accelerator) > 0;
}
return selectedInstanceType?.spec?.acceleratable;
}, [selectedInstanceType, action]);
const currentAccelerator =
Number(form.getFieldValue(['spec', 'resources', 'accelerator'])) || 1;
if (currentAccelerator > maxGpuCount) {
form.setFieldValue(['spec', 'resources', 'accelerator'], maxGpuCount);
const selectedInstanceData = useMemo(() => {
if (action === PageAction.EDIT) {
return JSON.parse(currentData?.description || '{}') || {};
}
return selectedInstanceType;
}, [selectedInstanceType, action, currentData]);
if (currentAccelerator < 1) {
form.setFieldValue(['spec', 'resources', 'accelerator'], 1);
}
}, [form, typeName, maxGpuCount, dataList]);
const renderInstanceType = () => {
const description = JSON.parse(currentData?.description || '{}').spec || {};
return (
<SelectedCard>
<Flex align="flex-start" orientation="vertical">
<span
style={{
color: 'var(--ant-color-text)',
fontWeight: 400
}}
>
{description.manufacturer
? `${description.product} x ${currentData?.spec?.resources?.accelerator}`
: ''}
</span>
</Flex>
</SelectedCard>
);
};
return (
<div data-field="instanceType">
@@ -115,17 +120,22 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
}
]}
>
<InstanceTypePicker dataList={dataList} value={typeName} />
{action === PageAction.CREATE && (
<InstanceTypePicker selectedInstanceType={selectedInstanceType} />
)}
{action === PageAction.EDIT && renderInstanceType()}
</Form.Item>
</FieldBlock>
{selectedInstanceType?.spec?.acceleratable && (
<Form.Item
{showGPUCount && (
<Form.Item<FormData>
name={['spec', 'resources', 'accelerator']}
hidden={action === PageAction.EDIT}
rules={[
{
required: true,
validator: (_, value) => {
if (value > maxGpuCount) {
const num = Number(value);
if (num > maxGpuCount) {
return Promise.reject(
new Error(
intl.formatMessage(
@@ -135,16 +145,12 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
)
);
}
if (value < 1) {
if (num < 1) {
return Promise.reject(
new Error(
intl.formatMessage(
{
id: 'gpuservice.instance.gpuCount.min'
},
{
count: 1
}
{ id: 'gpuservice.instance.gpuCount.min' },
{ count: 1 }
)
)
);
@@ -156,12 +162,14 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
>
<NumberSelection
min={1}
onChange={handleOnGPUCountChange}
max={maxGpuCount}
step={1}
required
disabled={disabled || action === PageAction.EDIT}
labelExtra={
!maxGpuCount && (
!maxGpuCount &&
action !== PageAction.EDIT && (
<Alert
showIcon
type="warning"
@@ -8,14 +8,12 @@ import GPUServiceStorageForm from '../../storage/forms';
interface StorageOverlayProps {
open: boolean;
namespace?: string;
onCancel: () => void;
onSubmit: (values: StorageFormData) => Promise<void> | void;
}
const StorageOverlay: React.FC<StorageOverlayProps> = ({
open,
namespace,
onCancel,
onSubmit
}) => {
@@ -66,7 +64,6 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
ref={formRef}
action={PageAction.CREATE}
open={open}
namespace={namespace}
onFinish={handleFinish}
/>
</FormOverlayView>
@@ -1,31 +1,36 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { PageAction } from '@/config';
import {
Input as CInput,
InputNumber as CInputNumber,
CheckboxField,
LabelInfo,
Select,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Flex, Form, Radio } from 'antd';
import { useAtomValue } from 'jotai';
import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
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 useQueryStorageTypes from '../../storage-types/services/use-query-storage-types';
import { DEFAULT_PV_CAPACITY_GB, StorageModeValueMap } from '../config';
import { FormData } from '../config/types';
import StorageOverlay from './storage-overlay';
const FieldBlock = styled.div`
margin-bottom: 24px;
`;
const DEFAULT_TEMP_CAPACITY_GB = 50;
const detectMode = (volume?: FormData['spec']['volume']) => {
if (volume?.persistentTemplate) {
return { mode: StorageModeValueMap.Persistent, releaseWithInstance: true };
}
if (volume?.persistent) {
return { mode: StorageModeValueMap.Persistent, releaseWithInstance: false };
}
return { mode: StorageModeValueMap.Temporary, releaseWithInstance: false };
};
const StorageVolume = ({
disabled,
action
@@ -35,50 +40,86 @@ const StorageVolume = ({
}) => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } =
useQueryStorage();
const [overlayOpen, setOverlayOpen] = useState(false);
const form = Form.useFormInstance<FormData>();
const currentVolume = Form.useWatch(['spec', 'volume'], form);
const [storageMode, setStorageMode] = useState<string>(
StorageModeValueMap.Temporary
);
const form = Form.useFormInstance<FormData>();
const currentCluster = useAtomValue(currentClusterAtom);
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const [releaseWithInstance, setReleaseWithInstance] = useState<boolean>(false);
useEffect(() => {
fetchStorage({});
if (!currentVolume) return;
const detected = detectMode(currentVolume);
setStorageMode(detected.mode);
setReleaseWithInstance(detected.releaseWithInstance);
}, [currentVolume]);
const { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } =
useQueryStorage();
const { detailData: storageTypesData, fetchData: fetchStorageTypes } =
useQueryStorageTypes();
const [overlayOpen, setOverlayOpen] = useState(false);
useEffect(() => {
fetchStorage({ page: 1, perPage: 100 });
fetchStorageTypes({ page: 1, perPage: 100 });
}, []);
const storageOptions = useMemo(
() =>
(storageData?.items || []).map((item) => ({
label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`,
value: item.metadata?.name
label: `${item.name} / ${item.spec?.capacity ?? '-'}`,
value: item.name
})),
[storageData]
);
const handleModeChange = (mode: string) => {
console.log('selected storage mode', mode);
if (mode === StorageModeValueMap.Existing) {
form.setFieldValue(['spec', 'volume'], { persistent: { name: '' } });
} else {
const storageTypeOptions = useMemo(
() =>
(storageTypesData?.items || []).map((item) => ({
label: item.displayName || item.name,
value: item.name
})),
[storageTypesData]
);
const applyMode = (mode: string, release: boolean) => {
if (mode === StorageModeValueMap.Temporary) {
form.setFieldValue(['spec', 'volume'], {
ephemeral: { capacity: `${DEFAULT_TEMP_CAPACITY_GB}Gi` }
});
return;
}
if (release) {
form.setFieldValue(['spec', 'volume'], {
persistentTemplate: {
spec: { type: '', capacity: `${DEFAULT_PV_CAPACITY_GB}Gi` },
releaseWithInstance: true
}
});
return;
}
form.setFieldValue(['spec', 'volume'], { persistent: { name: '' } });
};
const handleModeChange = (mode: string) => {
setStorageMode(mode);
applyMode(mode, releaseWithInstance);
};
const handleReleaseChange = (e: any) => {
const next = !!e?.target?.checked;
setReleaseWithInstance(next);
applyMode(storageMode, next);
};
const handleCreateStorage = async (values: StorageFormData) => {
try {
await createStorage({ data: values });
await fetchStorage({});
await fetchStorage({ page: 1, perPage: 100 });
form.setFieldValue(['spec', 'volume'], {
persistent: { name: values.metadata.name }
persistent: { name: values.name }
});
setOverlayOpen(false);
} catch (error) {
@@ -89,90 +130,48 @@ const StorageVolume = ({
return (
<>
<div data-field="storage"></div>
<Flex
style={{
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6
}}
>
<Radio.Group
disabled={disabled}
style={{ marginBottom: 12 }}
value={storageMode}
onChange={(e) => handleModeChange(e.target.value)}
options={[
{
label: (
<LabelInfo
description={intl.formatMessage({
id: 'gpuservice.storage.temporary.tips'
})}
label={
<span className="text-primary">
{intl.formatMessage({
id: 'gpuservice.storage.temporary'
})}
</span>
}
/>
),
value: StorageModeValueMap.Temporary
},
{
label: (
<LabelInfo
description={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.tips'
})}
label={
<span className="text-primary">
{intl.formatMessage({
id: 'gpuservice.storage.persistentVolume'
})}
</span>
}
/>
),
value: StorageModeValueMap.Existing
}
]}
/>
{action === PageAction.CREATE &&
storageMode === StorageModeValueMap.Existing && (
<Button
type="link"
size="small"
style={{ marginBottom: 6 }}
onClick={() => setOverlayOpen(true)}
>
{intl.formatMessage({ id: 'gpuservice.storage.add' })}
</Button>
)}
</Flex>
{storageMode === StorageModeValueMap.Existing && (
<Form.Item
name={['spec', 'volume', 'persistent', 'name']}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.required'
})
}
]}
>
<Select
disabled={disabled}
label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume'
})}
required
options={storageOptions}
/>
</Form.Item>
)}
<Radio.Group
disabled={disabled}
style={{ marginBottom: 12 }}
value={storageMode}
onChange={(e) => handleModeChange(e.target.value)}
options={[
{
label: (
<LabelInfo
description={intl.formatMessage({
id: 'gpuservice.storage.temporary.tips'
})}
label={
<span className="text-primary">
{intl.formatMessage({
id: 'gpuservice.storage.temporary'
})}
</span>
}
/>
),
value: StorageModeValueMap.Temporary
},
{
label: (
<LabelInfo
description={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.tips'
})}
label={
<span className="text-primary">
{intl.formatMessage({
id: 'gpuservice.storage.persistentVolume'
})}
</span>
}
/>
),
value: StorageModeValueMap.Persistent
}
]}
/>
{storageMode === StorageModeValueMap.Temporary && (
<Form.Item
@@ -203,6 +202,112 @@ const StorageVolume = ({
/>
</Form.Item>
)}
{storageMode === StorageModeValueMap.Persistent && (
<>
<Flex
style={{
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12
}}
>
<CheckboxField
disabled={disabled}
checked={releaseWithInstance}
onChange={handleReleaseChange}
label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.releaseWithInstance'
})}
/>
{action === PageAction.CREATE && !releaseWithInstance && (
<Button
type="link"
size="small"
onClick={() => setOverlayOpen(true)}
>
{intl.formatMessage({ id: 'gpuservice.storage.add' })}
</Button>
)}
</Flex>
{releaseWithInstance ? (
<>
<Form.Item
name={['spec', 'volume', 'persistentTemplate', 'spec', 'type']}
rules={[
{
required: true,
message: getRuleMessage('select', 'gpuservice.storageType')
}
]}
>
<Select
required
disabled={disabled}
label={intl.formatMessage({ id: 'gpuservice.storageType' })}
options={storageTypeOptions}
/>
</Form.Item>
<Form.Item
name={[
'spec',
'volume',
'persistentTemplate',
'spec',
'capacity'
]}
getValueProps={(val) => ({
value: val
? Number(String(val).replace(/Gi$/i, '')) || undefined
: undefined
})}
normalize={(val) => (val ? `${val}Gi` : '')}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.capacity.required'
})
}
]}
>
<CInputNumber
required
disabled={disabled}
min={1}
precision={0}
label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.capacity'
})}
/>
</Form.Item>
</>
) : (
<Form.Item
name={['spec', 'volume', 'persistent', 'name']}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.required'
})
}
]}
>
<Select
disabled={disabled}
label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume'
})}
required
options={storageOptions}
/>
</Form.Item>
)}
</>
)}
<Form.Item<FormData>
name={['spec', 'volumeMount']}
rules={[
@@ -226,7 +331,6 @@ const StorageVolume = ({
<StorageOverlay
open={overlayOpen}
namespace={namespace}
onCancel={() => setOverlayOpen(false)}
onSubmit={handleCreateStorage}
/>
@@ -34,7 +34,7 @@ type ConnectEntry =
const getConnectEntries = (record: ListItem): ConnectEntry[] => {
const ip = record.status?.hostIPs?.[0]?.ip;
const ports = record.status?.ports || [];
const hasSshKey = !!record.spec?.sshPublicKey?.name;
const hasSshKey = !!record.spec?.sshPublicKeys?.length;
const configPorts = record.spec?.ports || [];
if (!ip) {
@@ -66,6 +66,27 @@ const getConnectEntries = (record: ListItem): ConnectEntry[] => {
});
};
const renderInstanceType = (record: ListItem) => {
const description = JSON.parse(record?.description || '{}').spec || {};
return (
<Flex align="flex-start" orientation="vertical">
<span
style={{
color: 'var(--ant-color-text)',
fontWeight: 400
}}
>
{description.manufacturer
? `${description.product} x ${record.spec?.resources?.accelerator}`
: ''}
</span>
<span className="text-tertiary">
{`${record.spec.resources?.cpu}C / RAM: ${record.spec.resources?.ram?.replace('Gi', 'GB')} / Disk: ${record.spec.resources?.localStorage?.replace('Gi', 'GB')}`}{' '}
</span>
</Flex>
);
};
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void;
sortOrder: string[];
@@ -80,15 +101,15 @@ const useInstancesColumns = ({
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: ['metadata', 'name'],
dataIndex: 'name',
key: 'name',
sorter: false,
ellipsis: {
showTitle: false
},
render: (text: string) => (
render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 360 }}>
<span className="text-primary">{text}</span>
<span className="text-primary">{text || record.displayName}</span>
</AutoTooltip>
)
},
@@ -198,40 +219,19 @@ const useInstancesColumns = ({
ellipsis: {
showTitle: false
},
width: 230,
render: (text: string) => (
render: (_text: string, record: ListItem) => (
<AutoTooltip
ghost
style={{ maxWidth: 360, color: 'var(--ant-color-text) !important' }}
>
<Flex align="center">
<span
style={{
fontWeight: 400
}}
>
NVIDIA A100 (x2)
</span>
<span
className="text-secondary"
style={{
display: 'flex',
width: 4,
height: 4,
borderRadius: '50%',
backgroundColor: 'var(--ant-color-text-secondary)',
margin: '0 8px'
}}
></span>
<span className="text-tertiary">4C / 16GB</span>
</Flex>
{renderInstanceType(record)}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: ['metadata', 'creationTimestamp'],
key: 'creationTimestamp',
dataIndex: 'created_at',
key: 'created_at',
sorter: false,
ellipsis: {
showTitle: false
@@ -1,14 +1,9 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { ListItem } from '../config/types';
// View events still hits the K8s proxy. clusterID and namespace are taken
// from the row itself (`row.clusterId`, `row.status.namespace`).
const useViewEvents = () => {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
name: string;
@@ -22,13 +17,11 @@ const useViewEvents = () => {
});
const openModal = (row?: ListItem) => {
const name = row?.metadata?.name || '';
const rowNamespace = row?.metadata?.namespace || namespace;
setOpenModalStatus({
open: true,
name,
namespace: rowNamespace,
clusterID
name: row?.name || '',
namespace: row?.status?.namespace || '',
clusterID: row?.clusterId ?? undefined
});
};
@@ -1,15 +1,11 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { GPU_SERVICE_INSTANCES_LOG_API } from '../apis';
import { ListItem } from '../config/types';
// View logs still hits the K8s proxy. clusterID and namespace are taken
// from the row itself (`row.clusterId`, `row.status.namespace`) since the
// page no longer has a global cluster selector.
const useViewLogs = () => {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
url: string;
@@ -23,17 +19,14 @@ const useViewLogs = () => {
});
const openModal = (row?: ListItem) => {
const name = row?.metadata?.name;
const rowNamespace = row?.metadata?.namespace || namespace;
const name = row?.name;
const namespace = row?.status?.namespace;
const clusterID = row?.clusterId ?? undefined;
setOpenModalStatus({
open: true,
url:
name && clusterID
? `${GPU_SERVICE_INSTANCES_LOG_API({
namespace: rowNamespace,
clusterID,
name
})}`
name && namespace && clusterID
? GPU_SERVICE_INSTANCES_LOG_API({ namespace, clusterID, name })
: '',
tail: 1000,
status: row?.status?.phase || undefined
+72 -345
View File
@@ -1,14 +1,8 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
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 { handleBatchRequest } from '@/utils';
import { PlusOutlined } from '@ant-design/icons';
import {
BaseSelect,
DeleteModal,
DropdownButtons,
FilterBar,
@@ -17,26 +11,13 @@ import {
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import {
Button,
ConfigProvider,
Divider,
Flex,
message,
Modal,
Space,
Table
} from 'antd';
import { useAtom } from 'jotai';
import { Button, ConfigProvider, message, Modal, Space, Table } from 'antd';
import _ from 'lodash';
import { useCallback, useEffect, useMemo } from 'react';
import { PageContainerInner } from '../../_components/page-box';
import PageBox from '../../_components/page-box';
import {
deleteGPUServiceInstance,
GPU_SERVICE_INSTANCES_API,
queryGPUServiceInstances,
startGPUServiceInstance,
stopGPUServiceInstance
queryGPUServiceInstances
} from './apis';
import AddModal from './components/add-modal';
import ViewEventsModal from './components/view-events-modal';
@@ -52,72 +33,7 @@ import useUpdateInstance from './services/use-update-instance';
const GPUService: React.FC = () => {
const intl = useIntl();
const [modal, modalContextHolder] = Modal.useModal();
const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom);
const clusterID = currentCluster?.id;
// In admin "All" view there's no Org context, so the helper falls
// back to the selected cluster's owner Org name.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const deleteInstance = useCallback(
(id: number) => deleteGPUServiceInstance({ namespace, clusterID, id }),
[namespace, clusterID]
);
const startInstance = useCallback(
(row: ListItem) =>
startGPUServiceInstance({
namespace: row.metadata?.namespace || namespace,
clusterID,
name: row.metadata?.name as string
}),
[namespace, clusterID]
);
const stopInstance = useCallback(
(row: ListItem) =>
stopGPUServiceInstance({
namespace: row.metadata?.namespace || namespace,
clusterID,
name: row.metadata?.name as string
}),
[namespace, clusterID]
);
const fetchInstances = useMemoizedFn(
async (
params: any,
options?: any
): Promise<Global.PageResponse<ListItem>> => {
const effectiveClusterID = params.cluster_id ?? clusterID;
if (!effectiveClusterID) {
return {
items: [],
pagination: {
total: 0,
totalPage: 0,
page: 1,
perPage: params.perPage || 10
}
} as Global.PageResponse<ListItem>;
}
const res = await queryGPUServiceInstances(
{ ...params, namespace, clusterID: effectiveClusterID },
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
}
};
}
);
const [, modalContextHolder] = Modal.useModal();
const {
dataSource,
@@ -130,16 +46,14 @@ const GPUService: React.FC = () => {
fetchData,
handlePageChange,
handleTableChange,
handleQueryChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
key: PaginationKey.Instances,
fetchAPI: fetchInstances,
deleteAPI: deleteInstance,
watch: false,
polling: true,
API: GPU_SERVICE_INSTANCES_API({ namespace, clusterID }),
fetchAPI: queryGPUServiceInstances,
deleteAPI: deleteGPUServiceInstance,
watch: true,
API: GPU_SERVICE_INSTANCES_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.instance' })
});
@@ -160,55 +74,11 @@ const GPUService: React.FC = () => {
closeViewEventsModal,
openViewEventsModalStatus
} = useViewEvents();
const {
fetchClusterList,
cancelRequest: cancelClusterRequest,
clusterList
} = useQueryClusterList();
const k8sClusterList = useMemo(
() =>
clusterList.filter(
(item) => item.provider === ProviderValueMap.Kubernetes
),
[clusterList]
);
useEffect(() => {
fetchClusterList({ page: -1 }).then((clusters) => {
const k8sClusters = clusters.filter(
(item: any) => item.provider === ProviderValueMap.Kubernetes
);
if (k8sClusters.length === 0) {
return;
}
const storedCluster = k8sClusters.find(
(item: any) => item.id === currentCluster?.id
);
const targetCluster = storedCluster ?? k8sClusters[0];
if (!storedCluster) {
setCurrentCluster({
...targetCluster,
label: targetCluster.name,
value: targetCluster.id
});
}
handleQueryChange({
cluster_id: targetCluster.id,
page: 1
});
});
return () => {
cancelClusterRequest();
};
}, []);
const handleModalOk = async (data: FormData) => {
try {
if (openInstanceModalStatus.realAction === PageAction.CREATE) {
await deleteInstance(
openInstanceModalStatus.currentData!.metadata?.name as any
);
await deleteGPUServiceInstance(openInstanceModalStatus.currentData!.id);
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
@@ -226,161 +96,34 @@ const GPUService: React.FC = () => {
closeInstanceModal();
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
message.error(intl.formatMessage({ id: 'common.message.fail' }));
// ignore
}
};
const handleRecreate = useMemoizedFn((row: ListItem) => {
modalRef.current?.show({
title: intl.formatMessage({
id: 'gpuservice.instance.recreate.confirm.title'
}),
content: 'gpuservice.instance.recreate.confirm.content',
okText: 'common.button.recreate',
operation: 'gpuservice.instance.recreate.confirm.content',
name: row.metadata?.name,
onOk: async () => {
try {
await deleteInstance(row.metadata?.name as any);
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
await createInstance({
data: {
metadata: { name: row.metadata.name, namespace },
spec: row.spec
} as FormData
});
message.success(intl.formatMessage({ id: 'common.message.success' }));
handleSearch();
} catch (error) {
message.error(intl.formatMessage({ id: 'common.message.fail' }));
}
}
});
});
const handleStart = useMemoizedFn(async (row: ListItem) => {
modalRef.current?.show({
title: intl.formatMessage({ id: 'common.title.start.confirm' }),
content: 'gpuservice.instance',
okText: 'common.button.start',
operation: 'common.start.single.confirm',
name: row.metadata?.name,
async onOk() {
try {
await startInstance(row);
message.success(intl.formatMessage({ id: 'common.message.success' }));
fetchData();
} catch (error) {
// ignore
}
}
});
});
const handleStop = useMemoizedFn(async (row: ListItem) => {
modalRef.current?.show({
title: intl.formatMessage({ id: 'common.title.stop.confirm' }),
content: 'gpuservice.instance',
okText: 'common.button.stop',
operation: 'common.stop.single.confirm',
name: row.metadata?.name,
async onOk() {
try {
await stopInstance(row);
message.success(intl.formatMessage({ id: 'common.message.success' }));
fetchData();
} catch (error) {
// ignore
}
}
});
});
const handleStartBatch = useMemoizedFn(() => {
modalRef.current?.show({
title: intl.formatMessage({ id: 'common.title.start.confirm' }),
content: 'gpuservice.instance',
okText: 'common.button.start',
operation: 'common.start.confirm',
selection: true,
async onOk() {
const res = await handleBatchRequest(
rowSelection.selectedRows as ListItem[],
startInstance
);
fetchData();
return res;
}
});
});
const handleStopBatch = useMemoizedFn(() => {
modalRef.current?.show({
title: intl.formatMessage({ id: 'common.title.stop.confirm' }),
content: 'gpuservice.instance',
okText: 'common.button.stop',
operation: 'common.stop.confirm',
selection: true,
async onOk() {
const res = await handleBatchRequest(
rowSelection.selectedRows as ListItem[],
stopInstance
);
fetchData();
return res;
}
});
});
const handleSelect = useMemoizedFn((val: string, row: ListItem) => {
if (val === 'view') {
openViewInstanceModal(row);
} else if (val === 'edit') {
openEditInstanceModal(row);
} else if (val === 'delete') {
handleDelete({
...row,
name: row.metadata?.name,
id: row.metadata?.name as any
});
handleDelete({ ...row, name: row.name });
} else if (val === 'recreate') {
// Keep handleRecreate above for the old confirm-modal flow. The current
// UX opens the editable form and recreates after submit.
openRecreateInstanceModal(row);
} else if (val === 'viewlog') {
openViewLogsModal(row);
} else if (val === 'viewevent') {
openViewEventsModal(row);
} else if (val === 'start') {
handleStart(row);
} else if (val === 'stop') {
handleStop(row);
}
// start / stop are disabled until backend endpoints exist
});
const handleBatchActionSelect = useMemoizedFn((val: string) => {
if (val === 'start') {
handleStartBatch();
} else if (val === 'stop') {
handleStopBatch();
} else if (val === 'delete') {
if (val === 'delete') {
handleDeleteBatch();
}
// start / stop are disabled until backend endpoints exist
});
const handleClusterChange = (value: number) => {
const cluster = k8sClusterList.find((item) => item.value === value);
if (cluster) {
setCurrentCluster(cluster);
}
handleQueryChange({
cluster_id: value,
page: 1
});
};
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
@@ -389,7 +132,7 @@ const GPUService: React.FC = () => {
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-cloud-outlined" />}
filters={_.pick(queryParams, ['search', 'manufacturer'])}
filters={_.pick(queryParams, ['search'])}
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.instance.nofound'
})}
@@ -411,80 +154,64 @@ const GPUService: React.FC = () => {
});
return (
<PageContainerInner
leftContent={
<Flex align="center">
<span>{intl.formatMessage({ id: 'menu.gpuService.instances' })}</span>
<Divider
orientation="vertical"
style={{
marginLeft: 16
<>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
handleSearch={handleSearch}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
right={
<Space size={16}>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={openCreateInstanceModal}
>
{intl.formatMessage({ id: 'gpuservice.instance.add' })}
</Button>
<DropdownButtons
items={batchActionList}
extra={
rowSelection.selectedRowKeys.length > 0 && (
<span>({rowSelection.selectedRowKeys.length})</span>
)
}
size="large"
showText={true}
disabled={!rowSelection.selectedRowKeys.length}
onSelect={handleBatchActionSelect}
/>
</Space>
}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.id}
onChange={handleTableChange}
pagination={{
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
<BaseSelect
variant="borderless"
value={queryParams.cluster_id}
options={k8sClusterList}
onChange={handleClusterChange}
style={{ minWidth: 120, fontWeight: 500 }}
></BaseSelect>
</Flex>
}
>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
selectOptions={k8sClusterList}
select={{ showSearch: true }}
selectHolder={intl.formatMessage({
id: 'gpuservice.instance.filter.cluster'
})}
handleSearch={handleSearch}
handleSelectChange={handleClusterChange}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
right={
<Space size={16}>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={openCreateInstanceModal}
>
{intl.formatMessage({ id: 'gpuservice.instance.add' })}
</Button>
<DropdownButtons
items={batchActionList}
extra={
rowSelection.selectedRowKeys.length > 0 && (
<span>({rowSelection.selectedRowKeys.length})</span>
)
}
size="large"
showText={true}
disabled={!rowSelection.selectedRowKeys.length}
onSelect={handleBatchActionSelect}
/>
</Space>
}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.metadata.name}
onChange={handleTableChange}
pagination={false}
/>
</ConfigProvider>
</ConfigProvider>
</PageBox>
<AddModal
open={openInstanceModalStatus.open}
action={openInstanceModalStatus.action}
@@ -510,7 +237,7 @@ const GPUService: React.FC = () => {
/>
<DeleteModal ref={modalRef} />
{modalContextHolder}
</PageContainerInner>
</>
);
};
@@ -1,9 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { createGPUServiceInstance } from '../apis';
import { FormData, ListItem } from '../config/types';
@@ -12,31 +8,10 @@ interface CreateInstanceParams {
}
export default function useCreateInstance() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org name for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: CreateInstanceParams, option?: any) =>
createGPUServiceInstance(
{
namespace,
clusterID,
data: {
apiVersion,
kind: KindMapping.instance,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace, clusterID]
(params: CreateInstanceParams) =>
createGPUServiceInstance({ data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,33 +1,20 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryGPUServiceInstanceEvents } from '../apis';
import { InstanceEvents } from '../config/types';
interface QueryInstanceEventsParams {
name: string;
namespace: string;
clusterID?: number;
pretty?: string;
}
export default function useQueryInstanceEvents() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: QueryInstanceEventsParams, options?: any) =>
queryGPUServiceInstanceEvents(
{
namespace,
clusterID,
name: params.name,
pretty: params.pretty
},
options
),
[namespace, clusterID]
queryGPUServiceInstanceEvents(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,31 +1,19 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryGPUServiceInstanceLog } from '../apis';
import { InstanceLog, InstanceLogQueryParams } from '../config/types';
interface QueryInstanceLogParams extends InstanceLogQueryParams {
name: string;
namespace: string;
clusterID?: number;
}
export default function useQueryInstanceLog() {
const currentCluster = useAtomValue(currentClusterAtom);
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const clusterID = currentCluster?.id;
const fetchDetail = useCallback(
(params: QueryInstanceLogParams, options?: any) =>
queryGPUServiceInstanceLog(
{
...params,
namespace,
clusterID
},
options
),
[namespace, clusterID]
queryGPUServiceInstanceLog(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,32 +1,18 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryGPUServiceInstanceTypes } from '../apis';
import { transformInstanceType } from '../config';
import { InstanceTypeItem } from '../config/types';
export default function useQueryInstanceTypes() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const fetchDetail = useCallback(
async (params: Global.K8sSearchParams = {}, options?: any) => {
const res = await queryGPUServiceInstanceTypes(
{ ...params, clusterID },
options
);
return {
...res,
items: (res?.items || []).map(transformInstanceType)
};
},
[clusterID]
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServiceInstanceTypes(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.K8sPageResponse<InstanceTypeItem>,
Global.K8sSearchParams
Global.PageResponse<InstanceTypeItem>,
Global.SearchParams
>({
fetchDetail,
key: 'instanceTypes'
@@ -1,32 +1,19 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryGPUServiceInstancePVEvents } from '../apis';
import { InstanceEvents } from '../config/types';
interface QueryVolumeEventsParams {
name: string;
pretty?: string;
namespace: string;
clusterID?: number;
}
export default function useQueryVolumeEvents() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: QueryVolumeEventsParams, options?: any) =>
queryGPUServiceInstancePVEvents(
{
namespace,
clusterID,
name: params.name
},
options
),
[namespace, clusterID]
queryGPUServiceInstancePVEvents(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,44 +1,18 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { updateGPUServiceInstance } from '../apis';
import { FormData, ListItem } from '../config/types';
interface UpdateInstanceParams {
id: number;
data: FormData;
data: Partial<FormData>;
}
export default function useUpdateInstance() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org name for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: UpdateInstanceParams, option?: any) =>
updateGPUServiceInstance(
{
namespace,
clusterID,
id: params.id,
data: {
apiVersion,
kind: KindMapping.instance,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace, clusterID]
(params: UpdateInstanceParams) =>
updateGPUServiceInstance({ id: params.id, data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,10 +1,13 @@
import { request } from '@umijs/max';
import { FormData, ListItem } from '../types';
import { FormData, ListItem } from '../config/types';
export const GPU_SERVICE_PUBLIC_KEY_API = '/gpu-instance-ssh-public-keys/data';
export const GPU_SERVICE_PUBLIC_KEY_API = '/gpu-instance-ssh-public-keys';
export async function queryGPUServicePublicKeys(params: {}, options?: any) {
return request<ListItem>(GPU_SERVICE_PUBLIC_KEY_API, {
export async function queryGPUServicePublicKeys(
params: Global.SearchParams,
options?: any
) {
return request<Global.PageResponse<ListItem>>(GPU_SERVICE_PUBLIC_KEY_API, {
method: 'GET',
params,
cancelToken: options?.token
@@ -18,8 +21,11 @@ export async function createGPUServicePublicKey(params: { data: FormData }) {
});
}
export async function updateGPUServicePublicKey(params: { data: FormData }) {
return request<ListItem>(`${GPU_SERVICE_PUBLIC_KEY_API}`, {
export async function updateGPUServicePublicKey(params: {
id: number;
data: Omit<FormData, 'name'>;
}) {
return request<ListItem>(`${GPU_SERVICE_PUBLIC_KEY_API}/${params.id}`, {
method: 'PUT',
data: params.data
});
@@ -0,0 +1,70 @@
import { PageActionType } from '@/config/types';
import { ModalFooter } from '@gpustack/core-ui';
import { useRef } from 'react';
import FormDrawer from '../../../_components/form-drawer';
import { FormData, ListItem } from '../config/types';
import GPUServicePublicKeyForm from '../forms';
type AddPublicKeyModalProps = {
title: string;
action: PageActionType;
open: boolean;
onOk: (values: FormData) => void;
data?: ListItem | null;
onCancel: () => void;
};
const AddPublicKeyModal: React.FC<AddPublicKeyModalProps> = ({
title,
action,
open,
onOk,
data,
onCancel
}) => {
const form = useRef<any>(null);
const handleSubmit = () => {
form.current?.submit();
};
const handleCancel = () => {
form.current?.resetFields();
onCancel();
};
const onFinish = async (values: FormData) => {
onOk({ ...values });
};
return (
<FormDrawer
title={title}
open={open}
onCancel={handleCancel}
onSubmit={handleSubmit}
width={600}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
style={{
padding: '16px 24px 8px',
display: 'flex',
justifyContent: 'flex-end'
}}
/>
}
>
<GPUServicePublicKeyForm
ref={form}
action={action}
currentData={data}
onFinish={onFinish}
open={open}
/>
</FormDrawer>
);
};
export default AddPublicKeyModal;
@@ -0,0 +1,23 @@
export interface FormData {
name: string;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
spec: {
data: string;
};
}
export interface ListItem {
id: number;
created_at: string;
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
name?: string;
displayName?: string | null;
description?: string | null;
spec: {
data: string;
};
}
@@ -0,0 +1,69 @@
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import { Input as CInput, Textarea, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormData } from '../config/types';
const Basic = ({ action }: { action: string }) => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: getRuleMessage('input', 'common.table.name')
},
{
pattern: validateLabelNameRegxFor63,
message: intl.formatMessage({ id: 'gpuservice.form.rule.name' })
}
]}
>
<CInput.Input
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'common.table.name' })}
required
/>
</Form.Item>
<Form.Item<FormData> name="displayName">
<CInput.Input
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<Form.Item<FormData> name="description">
<Textarea
scaleSize={true}
label={intl.formatMessage({ id: 'common.table.description' })}
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 'data']}
rules={[
{
required: true,
message: getRuleMessage('input', 'gpuservice.publicKey.label')
}
]}
>
<Textarea
required
alwaysFocus
label={intl.formatMessage({ id: 'gpuservice.publicKey.label' })}
placeholder={intl.formatMessage({
id: 'gpuservice.publicKey.placeholder'
})}
trim={false}
autoSize={{ minRows: 6, maxRows: 12 }}
/>
</Form.Item>
</>
);
};
export default Basic;
@@ -0,0 +1,62 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { FormData, ListItem } from '../config/types';
import Basic from './basic';
interface PublicKeyFormProps {
ref?: any;
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
onFinish: (values: FormData) => Promise<void>;
}
const GPUServicePublicKeyForm: React.FC<PublicKeyFormProps> = forwardRef(
(props, ref) => {
const { action, currentData, open, onFinish } = props;
const [form] = Form.useForm<FormData>();
useEffect(() => {
if (!open) {
form.resetFields();
return;
}
if (action === PageAction.EDIT && currentData) {
form.setFieldsValue({
name: currentData.name as string,
displayName: currentData.displayName,
description: currentData.description,
spec: {
data: currentData.spec?.data ?? ''
}
});
}
}, [action, currentData, form, open]);
useImperativeHandle(ref, () => ({
submit: () => {
form.submit();
},
resetFields: () => {
form.resetFields();
}
}));
return (
<Form
name="gpuServicePublicKeyForm"
form={form}
onFinish={onFinish}
preserve={false}
initialValues={{}}
>
<Basic action={action} />
</Form>
);
}
);
export default GPUServicePublicKeyForm;
@@ -0,0 +1,52 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll';
import { useState } from 'react';
import { ListItem } from '../config/types';
const useCreatePublicKeyModal = () => {
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
title: string;
}>({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
const openModal = (
action: PageActionType,
title: string,
row?: ListItem | null
) => {
setOpenModalStatus({
open: true,
title,
action,
currentData: row ?? null
});
saveScrollHeight();
};
const closeModal = () => {
setOpenModalStatus({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
restoreScrollHeight();
};
return {
openPublicKeyModalStatus: openModalStatus,
openPublicKeyModal: openModal,
closePublicKeyModal: closeModal
};
};
export default useCreatePublicKeyModal;
@@ -0,0 +1,95 @@
import { AutoTooltip, DropdownButtons, icons } 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 { ListItem } from '../config/types';
const rowActionList = [
{
label: 'common.button.edit',
key: 'edit',
locale: true,
icon: icons.EditOutlined
},
{
label: 'common.button.delete',
key: 'delete',
locale: true,
icon: icons.DeleteOutlined,
props: {
danger: true
}
}
];
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void;
sortOrder: string[];
}
const usePublicKeyColumns = ({
handleSelect,
sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
return useMemo(() => {
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: 'name',
key: 'name',
sorter: false,
ellipsis: {
showTitle: false
},
render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 360 }}>
<span className="text-primary">{record.displayName || text}</span>
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.description' }),
dataIndex: 'description',
key: 'description',
sorter: false,
ellipsis: {
showTitle: false
},
render: (text: string) => (
<AutoTooltip ghost style={{ maxWidth: 360 }}>
{text || '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
key: 'created_at',
sorter: false,
ellipsis: {
showTitle: false
},
render: (text: string) => (
<AutoTooltip ghost>
{text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.operation' }),
key: 'operation',
dataIndex: 'operation',
render: (_text, record) => (
<DropdownButtons
items={rowActionList}
onSelect={(val) => handleSelect(val, record)}
/>
)
}
];
}, [handleSelect, sortOrder, intl]);
};
export default usePublicKeyColumns;
+154 -73
View File
@@ -1,94 +1,175 @@
import { Input as CInput, Textarea, useAppUtils } from '@gpustack/core-ui';
import { PageAction } from '@/config';
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import useTableFetch from '@/hooks/use-table-fetch';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Form, message } from 'antd';
import React, { useEffect } from 'react';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, message, Table } from 'antd';
import _ from 'lodash';
import PageBox from '../../_components/page-box';
import useGetSshkey from './services/use-get-sshkey';
import {
deleteGPUServicePublicKey,
GPU_SERVICE_PUBLIC_KEY_API,
queryGPUServicePublicKeys
} from './apis';
import AddPublicKeyModal from './components/add-public-key-modal';
import { FormData, ListItem } from './config/types';
import useCreatePublicKeyModal from './hooks/use-create-public-key-modal';
import usePublicKeyColumns from './hooks/use-public-key-columns';
import useCreateSshkey from './services/use-create-sshkey';
import useUpdateSshkey from './services/use-update-sshkey';
import { FormData } from './types';
const GPUServicePublicKeys: React.FC = () => {
const intl = useIntl();
const [form] = Form.useForm<FormData>();
const { getRuleMessage } = useAppUtils();
const { fetchData: fetchSshkey } = useGetSshkey();
const { fetchData: updateSshkey, loading: updating } = useUpdateSshkey();
useEffect(() => {
fetchSshkey({}).then((res) => {
const data = res?.spec?.data || '';
form.setFieldsValue({
name: res?.name,
spec: {
data
}
});
});
}, []);
const {
dataSource,
rowSelection,
queryParams,
sortOrder,
modalRef,
handleDelete,
handleDeleteBatch,
fetchData,
handlePageChange,
handleTableChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
key: 'PublicKeys',
fetchAPI: queryGPUServicePublicKeys,
deleteAPI: deleteGPUServicePublicKey,
watch: false,
polling: false,
API: GPU_SERVICE_PUBLIC_KEY_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.publicKey' })
});
const handleSave = async () => {
form.submit();
const { fetchData: createSshkey } = useCreateSshkey();
const { fetchData: updateSshkey } = useUpdateSshkey();
const { openPublicKeyModalStatus, openPublicKeyModal, closePublicKeyModal } =
useCreatePublicKeyModal();
const handleAdd = () => {
openPublicKeyModal(
PageAction.CREATE,
intl.formatMessage({ id: 'gpuservice.publicKey.add' })
);
};
const onFinish = async (values: FormData) => {
const handleEdit = (row: ListItem) => {
openPublicKeyModal(
PageAction.EDIT,
intl.formatMessage({ id: 'gpuservice.publicKey.edit' }),
row
);
};
const handleModalOk = async (data: FormData) => {
try {
await updateSshkey({
data: values
});
if (openPublicKeyModalStatus.action === PageAction.EDIT) {
await updateSshkey({
id: openPublicKeyModalStatus.currentData!.id,
data: data
});
} else {
await createSshkey({ data });
}
fetchData();
closePublicKeyModal();
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
// ignore
// it's handled in interceptor
}
};
const handleSelect = useMemoizedFn((val: string, row: ListItem) => {
if (val === 'edit') {
handleEdit(row);
} else if (val === 'delete') {
handleDelete({ ...row, name: row.name as string });
}
});
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-storage-outlined" />}
filters={_.pick(queryParams, ['search'])}
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.sshkey.nofound'
})}
title={intl.formatMessage({ id: 'noresult.gpuservice.sshkey.title' })}
subTitle={intl.formatMessage({
id: 'noresult.gpuservice.sshkey.subTitle'
})}
onClick={handleAdd}
buttonText={intl.formatMessage({ id: 'noresult.button.add' })}
/>
);
};
const columns = usePublicKeyColumns({
handleSelect,
sortOrder
});
return (
<PageBox>
<Form
style={{ width: 700 }}
name="gpuServicePublicKeyForm"
form={form}
onFinish={onFinish}
initialValues={{
name: 'SSHPublicKey',
spec: {
data: ''
}
}}
>
<Form.Item<FormData> name="name" hidden>
<CInput.Input />
</Form.Item>
<Form.Item<FormData>
name={['spec', 'data']}
rules={[
{
required: true,
message: getRuleMessage('input', 'gpuservice.publicKey.label')
}
]}
>
<Textarea
required
label={intl.formatMessage({ id: 'gpuservice.publicKey.label' })}
placeholder={intl.formatMessage({
id: 'gpuservice.publicKey.placeholder'
})}
trim={false}
alwaysFocus
autoSize={{ minRows: 8, maxRows: 16 }}
style={{ width: 700, minHeight: 186 }}
<>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
inputHolder={intl.formatMessage({
id: 'gpuservice.publicKey.filter.name'
})}
buttonText={intl.formatMessage({ id: 'gpuservice.publicKey.add' })}
handleSearch={handleSearch}
handleDeleteByBatch={handleDeleteBatch}
handleClickPrimary={handleAdd}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.id}
onChange={handleTableChange}
pagination={{
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
</Form.Item>
<Button
type="primary"
loading={updating}
style={{ width: 120, marginTop: 24 }}
onClick={handleSave}
>
{intl.formatMessage({ id: 'common.button.save' })}
</Button>
</Form>
</PageBox>
</ConfigProvider>
</PageBox>
<AddPublicKeyModal
open={openPublicKeyModalStatus.open}
action={openPublicKeyModalStatus.action}
title={openPublicKeyModalStatus.title}
data={openPublicKeyModalStatus.currentData}
onCancel={closePublicKeyModal}
onOk={handleModalOk}
/>
<DeleteModal ref={modalRef} />
</>
);
};
@@ -1,7 +1,7 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { createGPUServicePublicKey } from '../apis';
import { FormData, ListItem } from '../types';
import { FormData, ListItem } from '../config/types';
interface CreateSshkeyParams {
data: FormData;
@@ -1,24 +0,0 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServicePublicKeys } from '../apis';
import { ListItem } from '../types';
export default function useGetSshkey() {
const fetchDetail = useCallback(
(params: {}, options?: any) => queryGPUServicePublicKeys(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } =
useQueryData<ListItem>({
fetchDetail,
key: 'sshkey'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,27 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServicePublicKeys } from '../apis';
import { ListItem } from '../config/types';
export default function useQueryPublicKeys() {
const fetchDetail = useCallback(
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServicePublicKeys(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.PageResponse<ListItem>,
Global.SearchParams
>({
fetchDetail,
key: 'publicKeys'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,29 @@
import { useQueryDataList } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServicePublicKeys } from '../apis';
import { ListItem } from '../config/types';
export default function useQuerySshkeys() {
const fetchList = useCallback(
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServicePublicKeys(params, options),
[]
);
const { dataList, loading, cancelRequest, fetchData } = useQueryDataList<
ListItem,
Global.SearchParams
>({
key: 'sshkeyOptions',
fetchList,
getLabel: (item) => item?.name as string,
getValue: (item) => item?.name as string
});
return {
sshkeyOptions: dataList as Array<{ label: string; value: string }>,
loading,
cancelRequest,
fetchData
};
}
@@ -1,16 +1,17 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { updateGPUServicePublicKey } from '../apis';
import { FormData, ListItem } from '../types';
import { FormData, ListItem } from '../config/types';
interface UpdateSshkeyParams {
data: FormData;
id: number;
data: Omit<FormData, 'name'>;
}
export default function useUpdateSshkey() {
const fetchDetail = useCallback(
(params: UpdateSshkeyParams) =>
updateGPUServicePublicKey({ data: params.data }),
updateGPUServicePublicKey({ id: params.id, data: params.data }),
[]
);
@@ -1,13 +0,0 @@
export interface FormData {
name: string;
description: string;
spec: {
data: string;
};
}
export interface ListItem extends FormData {
id: number;
created_at?: string;
updated_at?: string;
}
@@ -0,0 +1,41 @@
import { request } from '@umijs/max';
import { FormData, ListItem } from '../config/types';
export const GPU_SERVICE_STORAGE_TYPE_API =
'/gpu-instance-persistent-volume-types';
export async function queryGPUServiceStorageTypes(
params: Global.SearchParams,
options?: any
) {
return request<Global.PageResponse<ListItem>>(GPU_SERVICE_STORAGE_TYPE_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
export async function createGPUServiceStorageType(params: {
data: Omit<FormData, 'type'>;
}) {
return request<ListItem>(GPU_SERVICE_STORAGE_TYPE_API, {
method: 'POST',
data: params.data
});
}
export async function updateGPUServiceStorageType(params: {
id: number;
data: Omit<FormData, 'type' | 'name'>;
}) {
return request<ListItem>(`${GPU_SERVICE_STORAGE_TYPE_API}/${params.id}`, {
method: 'PUT',
data: params.data
});
}
export async function deleteGPUServiceStorageType(id: number) {
return request(`${GPU_SERVICE_STORAGE_TYPE_API}/${id}`, {
method: 'DELETE'
});
}
@@ -0,0 +1,70 @@
import { PageActionType } from '@/config/types';
import { ModalFooter } from '@gpustack/core-ui';
import { useRef } from 'react';
import FormDrawer from '../../../_components/form-drawer';
import { FormData, ListItem } from '../config/types';
import GPUServiceStorageTypeForm from '../forms';
type AddStorageTypeModalProps = {
title: string;
action: PageActionType;
open: boolean;
onOk: (values: FormData) => void;
data?: ListItem | null;
onCancel: () => void;
};
const AddStorageTypeModal: React.FC<AddStorageTypeModalProps> = ({
title,
action,
open,
onOk,
data,
onCancel
}) => {
const form = useRef<any>(null);
const handleSubmit = () => {
form.current?.submit();
};
const handleCancel = () => {
form.current?.resetFields();
onCancel();
};
const onFinish = async (values: FormData) => {
onOk({ ...values });
};
return (
<FormDrawer
title={title}
open={open}
onCancel={handleCancel}
onSubmit={handleSubmit}
width={640}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
style={{
padding: '16px 24px 8px',
display: 'flex',
justifyContent: 'flex-end'
}}
/>
}
>
<GPUServiceStorageTypeForm
ref={form}
action={action}
currentData={data}
onFinish={onFinish}
open={open}
/>
</FormDrawer>
);
};
export default AddStorageTypeModal;
@@ -0,0 +1,54 @@
import { icons } from '@gpustack/core-ui';
import { StorageTypeKind } from './types';
export const StorageTypeKindValueMap: Record<string, StorageTypeKind> = {
NFS: 'nfs',
S3: 's3'
};
export const StorageTypeKindLabelMap: Record<string, string> = {
[StorageTypeKindValueMap.NFS]: 'NFS',
[StorageTypeKindValueMap.S3]: 'S3'
};
export const StorageTypeKindOptions = [
{ label: 'NFS', value: StorageTypeKindValueMap.NFS },
{ label: 'S3', value: StorageTypeKindValueMap.S3 }
];
export const DefaultNFSMountOptions = [
'hard',
'vers=4',
'rsize=1048576',
'wsize=1048576',
'noatime',
'nodiratime'
];
export const DefaultS3MountOptions = [
'--no-checksum',
'--memory-limit=4000',
'--max-flushers=32',
'--max-parallel-parts=32',
'--part-sizes=25',
'--list-type=2',
'--no-specials'
];
export const rowActionList = [
{
label: 'common.button.edit',
key: 'edit',
locale: true,
icon: icons.EditOutlined
},
{
label: 'common.button.delete',
key: 'delete',
locale: true,
icon: icons.DeleteOutlined,
props: {
danger: true
}
}
];
@@ -0,0 +1,46 @@
export type StorageTypeKind = 'nfs' | 's3';
export interface StorageTypeNFS {
server: string;
share: string;
subDirectory?: string | null;
mountPermissions?: string | null;
mountOptions?: string[] | null;
}
export interface StorageTypeS3 {
endpoint: string;
region?: string | null;
insecure?: boolean | null;
accessKey?: string | null;
secretKey?: string | null;
bucket?: string | null;
mountOptions?: string[] | null;
}
export interface FormData {
name: string;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
type: StorageTypeKind;
spec: {
nfs?: StorageTypeNFS | null;
s3?: StorageTypeS3 | null;
};
}
export interface ListItem {
id: number;
created_at: string;
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
name: string;
spec: {
nfs?: StorageTypeNFS | null;
s3?: Omit<StorageTypeS3, 'secretKey'> | null;
};
}
@@ -0,0 +1,73 @@
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import {
Input as CInput,
Select as SealSelect,
Textarea,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { StorageTypeKindOptions } from '../config';
import { FormData } from '../config/types';
const Basic = ({ action }: { action: string }) => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: getRuleMessage('input', 'common.table.name')
},
{
pattern: validateLabelNameRegxFor63,
message: intl.formatMessage({ id: 'gpuservice.form.rule.name' })
}
]}
>
<CInput.Input
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'common.table.name' })}
required
/>
</Form.Item>
<Form.Item<FormData> name="displayName">
<CInput.Input
trim={false}
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<Form.Item<FormData> name="description">
<Textarea
scaleSize={true}
trim={false}
label={intl.formatMessage({ id: 'common.table.description' })}
/>
</Form.Item>
<Form.Item<FormData>
name="type"
rules={[
{
required: true,
message: getRuleMessage('select', 'gpuservice.storageType.kind')
}
]}
>
<SealSelect
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'gpuservice.storageType.kind' })}
required
options={StorageTypeKindOptions}
/>
</Form.Item>
</>
);
};
export default Basic;
@@ -0,0 +1,129 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import {
DefaultNFSMountOptions,
DefaultS3MountOptions,
StorageTypeKindValueMap
} from '../config';
import { FormData, ListItem, StorageTypeKind } from '../config/types';
import Basic from './basic';
import NFSForm from './nfs';
import S3Form from './s3';
interface StorageTypeFormProps {
ref?: any;
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
onFinish: (values: FormData) => Promise<void>;
}
const detectKind = (item?: ListItem | null): StorageTypeKind => {
if (item?.spec?.s3) return StorageTypeKindValueMap.S3;
return StorageTypeKindValueMap.NFS;
};
const GPUServiceStorageTypeForm: React.FC<StorageTypeFormProps> = forwardRef(
(props, ref) => {
const { action, currentData, open, onFinish } = props;
const [form] = Form.useForm<FormData>();
const kind = Form.useWatch('type', form);
useEffect(() => {
if (!open) {
form.resetFields();
return;
}
if (action === PageAction.EDIT && currentData) {
const detected = detectKind(currentData);
form.setFieldsValue({
name: currentData.name,
displayName: currentData.displayName,
description: currentData.description,
type: detected,
spec: {
nfs: currentData.spec?.nfs ?? null,
s3: (currentData.spec?.s3 ?? null) as any
}
});
return;
}
form.setFieldsValue({
type: StorageTypeKindValueMap.NFS,
spec: {
nfs: {
server: '',
share: '',
mountOptions: DefaultNFSMountOptions
},
s3: null
}
});
}, [action, currentData, form, open]);
useEffect(() => {
if (!open || action === PageAction.EDIT) return;
if (kind === StorageTypeKindValueMap.S3) {
const existing = form.getFieldValue(['spec', 's3']);
if (!existing) {
form.setFieldValue(['spec', 's3'], {
endpoint: '',
insecure: false,
mountOptions: DefaultS3MountOptions
});
}
form.setFieldValue(['spec', 'nfs'], null);
} else if (kind === StorageTypeKindValueMap.NFS) {
const existing = form.getFieldValue(['spec', 'nfs']);
if (!existing) {
form.setFieldValue(['spec', 'nfs'], {
server: '',
share: '',
mountOptions: DefaultNFSMountOptions
});
}
form.setFieldValue(['spec', 's3'], null);
}
}, [kind, open, action, form]);
useImperativeHandle(ref, () => ({
submit: () => {
form.submit();
},
resetFields: () => {
form.resetFields();
}
}));
const handleFinish = async (values: FormData) => {
const isNFS = values.type === StorageTypeKindValueMap.NFS;
await onFinish({
...values,
spec: {
nfs: isNFS ? (values.spec?.nfs ?? null) : null,
s3: !isNFS ? (values.spec?.s3 ?? null) : null
}
});
};
return (
<Form
name="gpuServiceStorageTypeForm"
form={form}
onFinish={handleFinish}
preserve={false}
initialValues={{}}
>
<Basic action={action} />
{kind === StorageTypeKindValueMap.NFS && <NFSForm action={action} />}
{kind === StorageTypeKindValueMap.S3 && <S3Form action={action} />}
</Form>
);
}
);
export default GPUServiceStorageTypeForm;
@@ -0,0 +1,80 @@
import { PageAction } from '@/config';
import { Input as CInput, ListInput, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { FormData } from '../config/types';
const NFSForm = ({ action }: { action: string }) => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
return (
<>
<Form.Item<FormData>
name={['spec', 'nfs', 'server']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'gpuservice.storageType.nfs.server'
)
}
]}
>
<CInput.Input
required
label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.server'
})}
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 'nfs', 'share']}
rules={[
{
required: true,
message: getRuleMessage('input', 'gpuservice.storageType.nfs.share')
}
]}
>
<CInput.Input
required
label={intl.formatMessage({ id: 'gpuservice.storageType.nfs.share' })}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'subDirectory']}>
<CInput.Input
description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.subDirectory.tips'
})}
label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.subDirectory'
})}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'mountPermissions']}>
<CInput.Input
placeholder="0755,0777,..."
description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.mountPermissions.tips'
})}
label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.mountPermissions'
})}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'mountOptions']}>
<ListInput
disabled={action === PageAction.EDIT}
label={intl.formatMessage({
id: 'gpuservice.storageType.mountOptions'
})}
btnText={intl.formatMessage({ id: 'common.button.addParams' })}
/>
</Form.Item>
</>
);
};
export default NFSForm;
@@ -0,0 +1,109 @@
import { PageAction } from '@/config';
import {
CheckboxField,
Input as CInput,
ListInput,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd';
import { FormData } from '../config/types';
const S3Form = ({ action }: { action: string }) => {
console.log('action', action);
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
return (
<>
<Form.Item<FormData>
name={['spec', 's3', 'endpoint']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'gpuservice.storageType.s3.endpoint'
)
},
{
pattern: /^https?:\/\//,
message: intl.formatMessage({
id: 'gpuservice.storageType.s3.endpoint.rule'
})
}
]}
>
<CInput.Input
required
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.endpoint'
})}
placeholder="http | https://..."
/>
</Form.Item>
<Flex gap={16}>
<div style={{ flex: 1 }}>
<Form.Item<FormData> name={['spec', 's3', 'region']}>
<CInput.Input
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.region'
})}
/>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData> name={['spec', 's3', 'bucket']}>
<CInput.Input
description={intl.formatMessage({
id: 'gpuservice.storageType.s3.bucket.tips'
})}
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.bucket'
})}
/>
</Form.Item>
</div>
</Flex>
<Form.Item<FormData> name={['spec', 's3', 'accessKey']}>
<CInput.Input
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.accessKey'
})}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 's3', 'secretKey']}>
<CInput.Password
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.secretKey'
})}
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 's3', 'insecure']}
valuePropName="checked"
style={{ marginBottom: 12 }}
>
<CheckboxField
description={intl.formatMessage({
id: 'gpuservice.storageType.s3.insecure.tips'
})}
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.insecure'
})}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 's3', 'mountOptions']}>
<ListInput
disabled={action === PageAction.EDIT}
label={intl.formatMessage({
id: 'gpuservice.storageType.mountOptions'
})}
btnText={intl.formatMessage({ id: 'common.button.addParams' })}
/>
</Form.Item>
</>
);
};
export default S3Form;
@@ -0,0 +1,52 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll';
import { useState } from 'react';
import { ListItem } from '../config/types';
const useCreateStorageTypeModal = () => {
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
title: string;
}>({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
const openModal = (
action: PageActionType,
title: string,
row?: ListItem | null
) => {
setOpenModalStatus({
open: true,
title,
action,
currentData: row ?? null
});
saveScrollHeight();
};
const closeModal = () => {
setOpenModalStatus({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
restoreScrollHeight();
};
return {
openStorageTypeModalStatus: openModalStatus,
openStorageTypeModal: openModal,
closeStorageTypeModal: closeModal
};
};
export default useCreateStorageTypeModal;
@@ -0,0 +1,108 @@
import { FolderOutlined } from '@ant-design/icons';
import {
AutoTooltip,
DropdownButtons,
IconFont,
ThemeTag
} 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, StorageTypeKindLabelMap } from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void;
sortOrder: string[];
}
const getKindLabel = (record: ListItem) => {
if (record.spec?.nfs)
return (
<ThemeTag
style={{ width: 'fit-content' }}
color="cyan"
icon={<FolderOutlined />}
>
{StorageTypeKindLabelMap.nfs}
</ThemeTag>
);
if (record.spec?.s3)
return (
<ThemeTag
style={{ width: 'fit-content' }}
color="green"
icon={<IconFont type="icon-database" />}
>
{StorageTypeKindLabelMap.s3}
</ThemeTag>
);
return '-';
};
const useStorageTypeColumns = ({
handleSelect,
sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
return useMemo(() => {
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: 'name',
key: 'name',
sorter: false,
ellipsis: { showTitle: false },
render: (text: string, record: ListItem) => (
<AutoTooltip ghost minWidth={20}>
<span className="text-primary">{record.displayName || text}</span>
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.type' }),
key: 'kind',
sorter: false,
render: (_text, record) => getKindLabel(record)
},
{
title: intl.formatMessage({ id: 'common.table.description' }),
dataIndex: 'description',
key: 'description',
sorter: false,
ellipsis: { showTitle: false },
render: (text: string) => (
<AutoTooltip ghost minWidth={20}>
{text || '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
key: 'created_at',
sorter: false,
ellipsis: { showTitle: false },
render: (text: string) => (
<AutoTooltip ghost>
{text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.operation' }),
key: 'operation',
dataIndex: 'operation',
render: (_text, record) => (
<DropdownButtons
items={rowActionList}
onSelect={(val) => handleSelect(val, record)}
/>
)
}
];
}, [handleSelect, sortOrder, intl]);
};
export default useStorageTypeColumns;
@@ -0,0 +1,181 @@
import { PageAction } from '@/config';
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import useTableFetch from '@/hooks/use-table-fetch';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, message, Table } from 'antd';
import _ from 'lodash';
import PageBox from '../../_components/page-box';
import {
deleteGPUServiceStorageType,
GPU_SERVICE_STORAGE_TYPE_API,
queryGPUServiceStorageTypes
} from './apis';
import AddStorageTypeModal from './components/add-storage-type-modal';
import { FormData, ListItem } from './config/types';
import useCreateStorageTypeModal from './hooks/use-create-storage-type-modal';
import useStorageTypeColumns from './hooks/use-storage-type-columns';
import useCreateStorageType from './services/use-create-storage-type';
import useUpdateStorageType from './services/use-update-storage-type';
const GPUServiceStorageTypes: React.FC = () => {
const intl = useIntl();
const {
dataSource,
rowSelection,
queryParams,
sortOrder,
modalRef,
handleDelete,
handleDeleteBatch,
fetchData,
handlePageChange,
handleTableChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
key: 'StorageTypes',
fetchAPI: queryGPUServiceStorageTypes,
deleteAPI: deleteGPUServiceStorageType,
watch: false,
polling: false,
API: GPU_SERVICE_STORAGE_TYPE_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.storageType' })
});
const { fetchData: createStorageType } = useCreateStorageType();
const { fetchData: updateStorageType } = useUpdateStorageType();
const {
openStorageTypeModalStatus,
openStorageTypeModal,
closeStorageTypeModal
} = useCreateStorageTypeModal();
const handleAdd = () => {
openStorageTypeModal(
PageAction.CREATE,
intl.formatMessage({ id: 'gpuservice.storageType.add' })
);
};
const handleEdit = (row: ListItem) => {
openStorageTypeModal(
PageAction.EDIT,
intl.formatMessage({ id: 'gpuservice.storageType.edit' }),
row
);
};
const handleModalOk = async (data: FormData) => {
try {
if (openStorageTypeModalStatus.action === PageAction.EDIT) {
await updateStorageType({
id: openStorageTypeModalStatus.currentData!.id,
data: data
});
} else {
await createStorageType({ data: data });
}
fetchData();
closeStorageTypeModal();
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
// it's handled in interceptor
}
};
const handleSelect = useMemoizedFn((val: string, row: ListItem) => {
if (val === 'edit') {
handleEdit(row);
} else if (val === 'delete') {
handleDelete({ ...row, name: row.name });
}
});
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-storage-outlined" />}
filters={_.pick(queryParams, ['search'])}
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.storageType.nofound'
})}
title={intl.formatMessage({
id: 'noresult.gpuservice.storageType.title'
})}
subTitle={intl.formatMessage({
id: 'noresult.gpuservice.storageType.subTitle'
})}
onClick={handleAdd}
buttonText={intl.formatMessage({ id: 'noresult.button.add' })}
/>
);
};
const columns = useStorageTypeColumns({
handleSelect,
sortOrder
});
return (
<>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
inputHolder={intl.formatMessage({
id: 'gpuservice.storageType.filter.name'
})}
buttonText={intl.formatMessage({ id: 'gpuservice.storageType.add' })}
handleSearch={handleSearch}
handleDeleteByBatch={handleDeleteBatch}
handleClickPrimary={handleAdd}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.id}
onChange={handleTableChange}
pagination={{
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
</ConfigProvider>
</PageBox>
<AddStorageTypeModal
open={openStorageTypeModalStatus.open}
action={openStorageTypeModalStatus.action}
title={openStorageTypeModalStatus.title}
data={openStorageTypeModalStatus.currentData}
onCancel={closeStorageTypeModal}
onOk={handleModalOk}
/>
<DeleteModal ref={modalRef} />
</>
);
};
export default GPUServiceStorageTypes;
@@ -0,0 +1,31 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { createGPUServiceStorageType } from '../apis';
import { FormData, ListItem } from '../config/types';
interface CreateStorageTypeParams {
data: Omit<FormData, 'type'>;
}
export default function useCreateStorageType() {
const fetchDetail = useCallback(
(params: CreateStorageTypeParams) =>
createGPUServiceStorageType({ data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
CreateStorageTypeParams
>({
fetchDetail,
key: 'createStorageType'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,27 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServiceStorageTypes } from '../apis';
import { ListItem } from '../config/types';
export default function useQueryStorageTypes() {
const fetchDetail = useCallback(
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServiceStorageTypes(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.PageResponse<ListItem>,
Global.SearchParams
>({
fetchDetail,
key: 'storageTypes'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,32 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { updateGPUServiceStorageType } from '../apis';
import { FormData, ListItem } from '../config/types';
interface UpdateStorageTypeParams {
id: number;
data: Omit<FormData, 'type' | 'name'>;
}
export default function useUpdateStorageType() {
const fetchDetail = useCallback(
(params: UpdateStorageTypeParams) =>
updateGPUServiceStorageType({ id: params.id, data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
UpdateStorageTypeParams
>({
fetchDetail,
key: 'updateStorageType'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
+37 -106
View File
@@ -1,125 +1,56 @@
import { request } from '@umijs/max';
import { omitPathParams } from '../../utils';
import { ListItem, StorageClassItem } from '../config/types';
import {
FormData,
ListItem,
StorageClassItem,
UpdateData
} from '../config/types';
export const GPU_SERVICE_STORAGE_API = (params: {
namespace: string;
clusterID?: number;
}) =>
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instancepersistentvolumes`;
export const GPU_SERVICE_STORAGE_API = '/gpu-instance-persistent-volumes';
export const STORAGE_CLASS_API = (params: { clusterID?: number }) =>
`/clusters/${params.clusterID}/proxy/apis/storage.k8s.io/v1/storageclasses`;
export const STORAGE_CLASS_API = '/gpu-instance-persistent-volume-types';
export async function queryGPUServiceStorage(
params: Global.K8sSearchParams & {
namespace: string;
clusterID?: number;
},
params: Global.SearchParams,
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<ListItem>>(
GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
clusterID: params.clusterID
}),
{
method: 'GET',
params: omitPathParams(params),
cancelToken: options?.token
}
);
return request<Global.PageResponse<ListItem>>(GPU_SERVICE_STORAGE_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
export async function createGPUServiceStorage(
params: {
namespace: string;
clusterID?: number;
data: Global.K8sCommonData;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
clusterID: params.clusterID
}),
{
method: 'POST',
data: params.data,
cancelToken: option?.token
}
);
export async function createGPUServiceStorage(params: { data: FormData }) {
return request<ListItem>(GPU_SERVICE_STORAGE_API, {
method: 'POST',
data: params.data
});
}
export async function updateGPUServiceStorage(
params: {
namespace: string;
clusterID?: number;
id: number;
data: Global.K8sCommonData;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
`${GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
clusterID: params.clusterID
})}/${params.id}`,
{
method: 'PUT',
data: params.data,
cancelToken: option?.token
}
);
}
export async function deleteGPUServiceStorage(params: {
namespace: string;
clusterID?: number;
export async function updateGPUServiceStorage(params: {
id: number;
data: UpdateData;
}) {
if (!params.clusterID) {
return;
}
return request(
`${GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
clusterID: params.clusterID
})}/${params.id}`,
{
method: 'DELETE'
}
);
return request<ListItem>(`${GPU_SERVICE_STORAGE_API}/${params.id}`, {
method: 'PUT',
data: params.data
});
}
export async function deleteGPUServiceStorage(id: number) {
return request(`${GPU_SERVICE_STORAGE_API}/${id}`, {
method: 'DELETE'
});
}
export async function queryStorageClass(
params: Global.K8sSearchParams & {
clusterID?: number;
},
params: Global.SearchParams,
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<StorageClassItem>>(
STORAGE_CLASS_API({ clusterID: params.clusterID }),
{
method: 'GET',
params: omitPathParams(params),
cancelToken: options?.token,
headers: {
skipErrorHandler: 'true'
}
}
);
return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
+6 -83
View File
@@ -20,90 +20,13 @@ export const status: Record<string, StatusType> = {
[StoragePhaseValueMap.Pending]: StatusMaps.transitioning
};
export const StorageTypeValueMap = {
Local: 'local',
Shared: 'shared',
Object: 'object'
};
export const StorageTypeLabelMap: Record<string, string> = {
[StorageTypeValueMap.Local]: 'gpuservice.storage.type.local',
[StorageTypeValueMap.Shared]: 'gpuservice.storage.type.shared',
[StorageTypeValueMap.Object]: 'gpuservice.storage.type.object'
};
export const StorageTypeOptions = [
{
label: StorageTypeLabelMap[StorageTypeValueMap.Local],
value: StorageTypeValueMap.Local,
locale: true
},
{
label: StorageTypeLabelMap[StorageTypeValueMap.Shared],
value: StorageTypeValueMap.Shared,
locale: true
},
{
label: StorageTypeLabelMap[StorageTypeValueMap.Object],
value: StorageTypeValueMap.Object,
locale: true
}
];
export const AccessModeValueMap = {
ReadWriteOnce: 'ReadWriteOnce',
ReadOnlyMany: 'ReadOnlyMany',
ReadWriteMany: 'ReadWriteMany',
ReadWriteOncePod: 'ReadWriteOncePod'
};
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 ReClaimPolicyOptions = [
{
label: 'Retain',
value: ReClaimPolicyValueMap.Retain
},
{
label: 'Delete',
value: ReClaimPolicyValueMap.Delete
},
{
label: 'Recycle',
value: ReClaimPolicyValueMap.Recycle
}
];
export const rowActionList = [
// {
// label: 'common.button.edit',
// key: 'edit',
// locale: true,
// icon: icons.EditOutlined
// },
{
label: 'common.button.edit',
key: 'edit',
locale: true,
icon: icons.EditOutlined
},
{
label: 'common.button.delete',
key: 'delete',
+55 -47
View File
@@ -1,60 +1,68 @@
export type AccessModeType =
| 'ReadOnlyMany'
| 'ReadWriteMany'
| 'ReadWriteOnce'
| 'ReadWriteOncePod';
export type ReClaimPolicyType = 'Retain' | 'Delete' | 'Recycle';
export interface FormData {
metadata: {
name: string;
namespace: string;
};
spec: {
capacity: string;
type: string;
};
}
export interface ManagedField {
manager: string;
operation: string;
apiVersion: string;
time: string;
fieldsType: string;
fieldsV1: Record<string, any>;
}
export interface Metadata {
name: string;
namespace?: string;
uid: string;
resourceVersion: string;
creationTimestamp: string;
annotations?: Record<string, string>;
finalizers?: string[];
managedFields?: ManagedField[];
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
spec: {
type: string;
capacity: string;
};
}
// Per `GPUInstancePersistentVolumeUpdate`, only displayName / description /
// owner_principal_id are mutable post-create.
export interface UpdateData {
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
}
export interface ListItem {
metadata: Metadata;
id: number;
created_at: string;
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
name: string;
spec: {
type: string;
capacity: string;
accessMode: AccessModeType;
};
status: {
phaseMessage?: string;
phase: 'Available' | 'Unavailable' | 'Pending';
volume: {
name: string;
};
};
status?: {
phase?: string | null;
} | null;
}
export interface StorageClassNFS {
server: string;
share: string;
subDirectory?: string | null;
mountPermissions?: string | null;
mountOptions?: string[] | null;
}
export interface StorageClassS3 {
endpoint: string;
region?: string | null;
insecure?: boolean | null;
accessKey?: string | null;
bucket?: string | null;
mountOptions?: string[] | null;
}
export interface StorageClassItem {
metadata: Metadata;
provisioner: string;
reclaimPolicy: 'Delete' | 'Retain';
volumeBindingMode: 'Immediate' | 'WaitForFirstConsumer';
id: number;
created_at: string;
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
name: string;
spec: {
nfs?: StorageClassNFS | null;
s3?: StorageClassS3 | null;
};
}
+21 -3
View File
@@ -3,11 +3,13 @@ import {
Input as CInput,
InputNumber,
Select as SealSelect,
Textarea,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd';
import { useEffect } from 'react';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormData } from '../config/types';
import useQueryStorageClass from '../services/use-query-storage-class';
@@ -18,14 +20,15 @@ const Basic = ({ open, action }: { open: boolean; action: string }) => {
useEffect(() => {
if (open) {
fetchData({});
fetchData({ page: 1, perPage: 100 });
}
}, [open]);
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
name={['metadata', 'name']}
name="name"
rules={[
{
required: true,
@@ -43,6 +46,19 @@ const Basic = ({ open, action }: { open: boolean; action: string }) => {
required
/>
</Form.Item>
<Form.Item<FormData> name="displayName">
<CInput.Input
trim={false}
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<Form.Item<FormData> name="description">
<Textarea
trim={false}
scaleSize={true}
label={intl.formatMessage({ id: 'common.table.description' })}
/>
</Form.Item>
<Flex gap={16}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
@@ -79,7 +95,9 @@ const Basic = ({ open, action }: { open: boolean; action: string }) => {
>
<InputNumber
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'gpuservice.storage.capacity' })}
label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.capacity'
})}
required
/>
</Form.Item>
+4 -12
View File
@@ -10,19 +10,12 @@ 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,
namespace = 'default',
onFinish
} = props;
const { action, currentData, open, onFinish } = props;
const [form] = Form.useForm<FormData>();
useEffect(() => {
@@ -33,10 +26,9 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
if (action === PageAction.EDIT && currentData) {
form.setFieldsValue({
metadata: {
name: currentData.metadata?.name,
namespace: currentData.metadata?.namespace
},
name: currentData.name,
displayName: currentData.displayName,
description: currentData.description,
spec: {
capacity: currentData.spec?.capacity,
type: currentData.spec?.type
@@ -0,0 +1,52 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll';
import { useState } from 'react';
import { ListItem } from '../config/types';
const useCreateStorageModal = () => {
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
title: string;
}>({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
const openModal = (
action: PageActionType,
title: string,
row?: ListItem | null
) => {
setOpenModalStatus({
open: true,
title,
action,
currentData: row ?? null
});
saveScrollHeight();
};
const closeModal = () => {
setOpenModalStatus({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
restoreScrollHeight();
};
return {
openStorageModalStatus: openModalStatus,
openStorageModal: openModal,
closeStorageModal: closeModal
};
};
export default useCreateStorageModal;
@@ -3,12 +3,7 @@ import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import {
rowActionList,
status,
StoragePhaseLabelMap,
StorageTypeLabelMap
} from '../config';
import { rowActionList, status, StoragePhaseLabelMap } from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
@@ -25,15 +20,15 @@ const useStorageColumns = ({
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: ['metadata', 'name'],
dataIndex: 'name',
key: 'name',
sorter: false,
ellipsis: {
showTitle: false
},
render: (text: string) => (
render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 360 }}>
<span className="text-primary">{text}</span>
<span className="text-primary">{record.displayName || text}</span>
</AutoTooltip>
)
},
@@ -42,10 +37,7 @@ const useStorageColumns = ({
dataIndex: ['spec', 'type'],
key: 'type',
sorter: false,
render: (value: string) =>
StorageTypeLabelMap[value]
? intl.formatMessage({ id: StorageTypeLabelMap[value] })
: value || '-'
render: (value: string) => value || '-'
},
{
title: intl.formatMessage({ id: 'gpuservice.storage.capacity' }),
@@ -59,20 +51,22 @@ const useStorageColumns = ({
dataIndex: ['status', 'phase'],
key: 'status',
sorter: false,
render: (value: string, record: ListItem) => (
<StatusTag
statusValue={{
status: status[value],
text: StoragePhaseLabelMap[value] || value,
message: record.status?.phaseMessage || ''
}}
></StatusTag>
)
render: (value: string) =>
value ? (
<StatusTag
statusValue={{
status: status[value],
text: StoragePhaseLabelMap[value] || value
}}
></StatusTag>
) : (
'-'
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: ['metadata', 'creationTimestamp'],
key: 'creationTimestamp',
dataIndex: 'created_at',
key: 'created_at',
sorter: false,
ellipsis: {
showTitle: false
+43 -193
View File
@@ -1,85 +1,26 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } 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 {
BaseSelect,
DeleteModal,
FilterBar,
IconFont,
NoResult
} from '@gpustack/core-ui';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, Divider, Flex, message, Table } from 'antd';
import { useAtom } from 'jotai';
import { ConfigProvider, message, Table } from 'antd';
import _ from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { PageContainerInner } from '../../_components/page-box';
import PageBox from '../../_components/page-box';
import {
deleteGPUServiceStorage,
GPU_SERVICE_STORAGE_API,
queryGPUServiceStorage
} from './apis';
import AddModal from './components/add-modal';
import { FormData, ListItem } from './config/types';
import useCreateStorageModal from './hooks/use-create-storage-modal';
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 intl = useIntl();
const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view falls back to the cluster's owner Org name —
// see :func:`getCurrentOrgNamespace`.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const deleteStorage = useCallback(
(id: number) => deleteGPUServiceStorage({ namespace, clusterID, id }),
[namespace, clusterID]
);
const fetchStorage = useCallback(
async (
params: any,
options?: any
): Promise<Global.PageResponse<ListItem>> => {
const effectiveClusterID = params.cluster_id ?? clusterID;
if (!effectiveClusterID) {
return {
items: [],
pagination: {
total: 0,
totalPage: 0,
page: 1,
perPage: params.perPage || 10
}
} as Global.PageResponse<ListItem>;
}
const res = await queryGPUServiceStorage(
{ ...params, namespace, clusterID: effectiveClusterID },
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, clusterID]
);
const {
dataSource,
@@ -92,116 +33,55 @@ const GPUServiceStorage: React.FC = () => {
fetchData,
handlePageChange,
handleTableChange,
handleQueryChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
key: PaginationKey.Storage,
fetchAPI: fetchStorage,
deleteAPI: deleteStorage,
fetchAPI: queryGPUServiceStorage,
deleteAPI: deleteGPUServiceStorage,
watch: false,
polling: true,
API: GPU_SERVICE_STORAGE_API({ namespace, clusterID }),
API: GPU_SERVICE_STORAGE_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.storage' })
});
const { fetchData: createStorage } = useCreateStorage();
const { fetchData: updateStorage } = useUpdateStorage();
const {
fetchClusterList,
cancelRequest: cancelClusterRequest,
clusterList
} = useQueryClusterList();
const k8sClusterList = useMemo(
() =>
clusterList.filter(
(item) => item.provider === ProviderValueMap.Kubernetes
),
[clusterList]
);
const [openAddModalStatus, setOpenAddModalStatus] = useState<{
action: PageActionType;
open: boolean;
title: string;
currentData?: ListItem | null;
}>({
action: PageAction.CREATE,
title: '',
open: false,
currentData: null
});
useEffect(() => {
fetchClusterList({ page: -1 }).then((clusters) => {
const k8sClusters = clusters.filter(
(item: any) => item.provider === ProviderValueMap.Kubernetes
);
if (k8sClusters.length === 0) {
return;
}
const storedCluster = k8sClusters.find(
(item: any) => item.id === currentCluster?.id
);
const targetCluster = storedCluster ?? k8sClusters[0];
if (!storedCluster) {
setCurrentCluster({
...targetCluster,
label: targetCluster.name,
value: targetCluster.id
});
}
handleQueryChange({
cluster_id: targetCluster.id,
page: 1
});
});
return () => {
cancelClusterRequest();
};
}, []);
const { openStorageModalStatus, openStorageModal, closeStorageModal } =
useCreateStorageModal();
const handleAddStorage = () => {
setOpenAddModalStatus({
action: PageAction.CREATE,
title: intl.formatMessage({ id: 'gpuservice.storage.add' }),
open: true,
currentData: null
});
openStorageModal(
PageAction.CREATE,
intl.formatMessage({ id: 'gpuservice.storage.add' })
);
};
const handleEditStorage = (row: ListItem) => {
setOpenAddModalStatus({
action: PageAction.EDIT,
title: intl.formatMessage({ id: 'gpuservice.storage.edit' }),
open: true,
currentData: row
});
};
const closeModal = () => {
setOpenAddModalStatus({
action: PageAction.CREATE,
title: '',
open: false,
currentData: null
});
openStorageModal(
PageAction.EDIT,
intl.formatMessage({ id: 'gpuservice.storage.edit' }),
row
);
};
const handleModalOk = async (data: FormData) => {
try {
if (openAddModalStatus.action === PageAction.EDIT) {
if (openStorageModalStatus.action === PageAction.EDIT) {
await updateStorage({
id: openAddModalStatus.currentData!.metadata?.name as any,
data
id: openStorageModalStatus.currentData!.id,
data: {
owner_principal_id: data.owner_principal_id,
displayName: data.displayName,
description: data.description
}
});
} else {
await createStorage({ data });
}
fetchData();
closeModal();
closeStorageModal();
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
message.error(intl.formatMessage({ id: 'common.message.fail' }));
@@ -214,23 +94,11 @@ const GPUServiceStorage: React.FC = () => {
} else if (val === 'delete') {
handleDelete({
...row,
name: row.metadata?.name,
id: row.metadata?.name as any
id: row.id
});
}
});
const handleClusterChange = (value: number) => {
const cluster = k8sClusterList.find((item) => item.value === value);
if (cluster) {
setCurrentCluster(cluster);
}
handleQueryChange({
cluster_id: value,
page: 1
});
};
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
@@ -260,38 +128,13 @@ const GPUServiceStorage: React.FC = () => {
return (
<>
<PageContainerInner
leftContent={
<Flex align="center">
<span>{intl.formatMessage({ id: 'menu.gpuService.storage' })}</span>
<Divider
orientation="vertical"
style={{
marginLeft: 16
}}
/>
<BaseSelect
variant="borderless"
value={queryParams.cluster_id}
options={k8sClusterList}
style={{ minWidth: 120, fontWeight: 500 }}
onChange={handleClusterChange}
></BaseSelect>
</Flex>
}
>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
selectOptions={k8sClusterList}
select={{ showSearch: true }}
selectHolder={intl.formatMessage({
id: 'gpuservice.storage.filter.cluster'
})}
buttonText={intl.formatMessage({ id: 'gpuservice.storage.add' })}
handleSearch={handleSearch}
handleSelectChange={handleClusterChange}
handleDeleteByBatch={handleDeleteBatch}
handleClickPrimary={handleAddStorage}
handleInputChange={handleNameChange}
@@ -309,18 +152,25 @@ const GPUServiceStorage: React.FC = () => {
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.metadata?.name as any}
rowKey={(record) => record.id}
onChange={handleTableChange}
pagination={false}
pagination={{
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
</ConfigProvider>
</PageContainerInner>
</PageBox>
<AddModal
open={openAddModalStatus.open}
action={openAddModalStatus.action}
title={openAddModalStatus.title}
data={openAddModalStatus.currentData}
onCancel={closeModal}
open={openStorageModalStatus.open}
action={openStorageModalStatus.action}
title={openStorageModalStatus.title}
data={openStorageModalStatus.currentData}
onCancel={closeStorageModal}
onOk={handleModalOk}
/>
<DeleteModal ref={modalRef} />
@@ -1,9 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { createGPUServiceStorage } from '../apis';
import { FormData, ListItem } from '../config/types';
@@ -12,31 +8,10 @@ interface CreateStorageParams {
}
export default function useCreateStorage() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org name for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: CreateStorageParams, option?: any) =>
createGPUServiceStorage(
{
namespace,
clusterID,
data: {
apiVersion,
kind: KindMapping.instancePersistentVolume,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace, clusterID]
(params: CreateStorageParams) =>
createGPUServiceStorage({ data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,28 +1,23 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { useQueryDataList } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryStorageClass } from '../apis';
import { StorageClassItem } from '../config/types';
export default function useQueryStorageClass() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const fetchList = useCallback(
(params: Global.K8sSearchParams = {}, options?: any) =>
queryStorageClass({ ...params, clusterID }, options),
[clusterID]
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryStorageClass(params, options),
[]
);
const { dataList, loading, cancelRequest, fetchData } = useQueryDataList<
StorageClassItem,
Global.K8sSearchParams
Global.SearchParams
>({
key: 'storageClass',
fetchList,
getLabel: (item) => item?.metadata.name,
getValue: (item) => item?.metadata.name
getLabel: (item) => item?.displayName || item?.name,
getValue: (item) => item?.name
});
return {
@@ -1,27 +1,18 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryGPUServiceStorage } from '../apis';
import { ListItem } from '../config/types';
export default function useQueryStorage() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org name for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: Global.K8sSearchParams = {}, options?: any) =>
queryGPUServiceStorage({ ...params, namespace, clusterID }, options),
[namespace, clusterID]
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServiceStorage(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.K8sPageResponse<ListItem>,
Global.K8sSearchParams
Global.PageResponse<ListItem>,
Global.SearchParams
>({
fetchDetail,
key: 'storage'
@@ -1,44 +1,18 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { updateGPUServiceStorage } from '../apis';
import { FormData, ListItem } from '../config/types';
import { ListItem, UpdateData } from '../config/types';
interface UpdateStorageParams {
id: number;
data: FormData;
data: UpdateData;
}
export default function useUpdateStorage() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org name for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: UpdateStorageParams, option?: any) =>
updateGPUServiceStorage(
{
namespace,
clusterID,
id: params.id,
data: {
apiVersion,
kind: KindMapping.instancePersistentVolume,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace, clusterID]
(params: UpdateStorageParams) =>
updateGPUServiceStorage({ id: params.id, data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -58,7 +58,7 @@ const Basic: React.FC<BasicProps> = ({
<Flex gap={4} align="center">
{label}
<span>
({intl.formatMessage({ id: 'common.remaining' }, { count: max })})
({intl.formatMessage({ id: 'common.max' }, { count: max })})
</span>
</Flex>
);
@@ -126,6 +126,7 @@ const Basic: React.FC<BasicProps> = ({
id: 'gpuservice.template.displayName'
})}
showCount
trim={false}
maxLength={63}
/>
</Form.Item>