From 50601b0affc7461b755c71abce45daa6e8da0840 Mon Sep 17 00:00:00 2001 From: michelia Date: Thu, 4 Jun 2026 18:22:21 +0800 Subject: [PATCH] feat(gpu-service): instance-type spec popover with per-card specs Replace the raw flavor slug in the GPU Instances list with the product name (" x ") plus an info-icon popover that breaks the spec down by category (GPU / CPU / Memory / Disk): per-card VRAM, whole-instance CPU/RAM, system / ephemeral / persistent disks (persistent size resolved from the referenced PV). Extract the cell into a shared InstanceTypeCell and centralize memory formatting in formatMemoryDisplay so the GPU Instances list and the Usage tab render identical sizes. --- .../components/instance-type-cell.tsx | 89 +++++++++++++ .../components/instance-type-item.tsx | 8 +- .../gpu-service/instances/config/index.ts | 15 +++ .../instances/hooks/use-instances-columns.tsx | 123 ++++++++++++------ src/pages/gpu-service/instances/index.tsx | 27 +++- 5 files changed, 214 insertions(+), 48 deletions(-) create mode 100644 src/pages/gpu-service/instances/components/instance-type-cell.tsx diff --git a/src/pages/gpu-service/instances/components/instance-type-cell.tsx b/src/pages/gpu-service/instances/components/instance-type-cell.tsx new file mode 100644 index 00000000..a670e451 --- /dev/null +++ b/src/pages/gpu-service/instances/components/instance-type-cell.tsx @@ -0,0 +1,89 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import { AutoTooltip, IconFont } from '@gpustack/core-ui'; +import { Flex, Tooltip } from 'antd'; +import React from 'react'; + +export type InstanceTypeSection = { + icon: string; + name: string; + // [label, value] — a null label renders a single value with no sub-label; + // rows whose value is falsy or "-" are dropped. + rows: [string | null, string | undefined][]; +}; + +/** + * "Instance Type" cell shared by the GPU Instances list and the Usage GPU + * Instances table: a primary product label (e.g. "NVIDIA-GeForce-RTX-5090-D x + * 1") plus a question/info icon whose dark popover breaks the spec down by + * category (GPU / CPU / Memory / Disk), titled with the instance name. + */ +const InstanceTypeCell: React.FC<{ + title: string; + name?: string; + sections: InstanceTypeSection[]; +}> = ({ title, name, sections }) => { + const specInfo = ( +
+ {name &&
{name}
} + {sections.map((sec) => { + const rows = sec.rows.filter(([, v]) => v && v !== '-'); + if (!rows.length) return null; + return ( +
+
+ + {sec.name} +
+
+ {rows.map(([label, value], i) => ( +
+ + {label || ''} + + {value} +
+ ))} +
+
+ ); + })} +
+ ); + + return ( + + {title}}> + {title} + + + + + + ); +}; + +export default InstanceTypeCell; diff --git a/src/pages/gpu-service/instances/components/instance-type-item.tsx b/src/pages/gpu-service/instances/components/instance-type-item.tsx index 4b03b132..b320e3fd 100644 --- a/src/pages/gpu-service/instances/components/instance-type-item.tsx +++ b/src/pages/gpu-service/instances/components/instance-type-item.tsx @@ -3,12 +3,9 @@ import { useIntl } from '@umijs/max'; import { Flex } from 'antd'; import styled from 'styled-components'; import { manufactureColorMap } from '../../templates/config'; -import { convertKiToGi } from '../config'; +import { formatMemoryDisplay } from '../config'; import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types'; -const toDisplayUnit = (value?: string | null) => - value ? value.replace(/Gi$/, 'GB').replace(/Ti$/, 'TB') : value; - const Title = styled.div` display: flex; align-items: center; @@ -136,8 +133,7 @@ const InstanceTypeItem: React.FC = ({ item }) => { icon="icon-gpu1" label={intl.formatMessage({ id: 'gpuservice.instance.memory' })} value={ - toDisplayUnit(convertKiToGi(specData?.memory ?? undefined)) ?? - '-' + formatMemoryDisplay(specData?.memory ?? undefined) ?? '-' } /> { return `${_.floor(Number(num) / GI_DIVISOR[unit], 0)} Gi`; }; +// Memory quantity → display string, flooring to whole Gi. Accepts a k8s +// quantity string ("16Gi" / "32607Mi") or a raw MiB number (as the Usage +// breakdown carries it). Centralizes the conversion so the GPU Instances list +// and the Usage tab render identical sizes (e.g. both "31GB", not 31 vs 32). +export const formatMemoryDisplay = ( + value?: string | number +): string | undefined => { + if (!value) return undefined; + const quantity = typeof value === 'number' ? `${value}Mi` : value; + return ( + convertKiToGi(quantity)?.replace(/Gi$/, 'GB').replace(/Ti$/, 'TB') || + undefined + ); +}; + const parseQuantity = (value?: string | null): number => { if (!value) return 0; const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value)); diff --git a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx index 7dad8d7f..fd43b10e 100644 --- a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx +++ b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx @@ -6,15 +6,20 @@ import { StatusTag } from '@gpustack/core-ui'; import { useAccess, useIntl } from '@umijs/max'; -import { Button, Flex } from 'antd'; +import { Button } from 'antd'; import type { ColumnsType } from 'antd/lib/table'; import dayjs from 'dayjs'; import _ from 'lodash'; import { Fragment, useMemo } from 'react'; import { parseJsonSafe } from '../../utils'; -import { InstanceStatusLabelMap, rowActionList, status } from '../config'; +import InstanceTypeCell from '../components/instance-type-cell'; +import { + formatMemoryDisplay, + InstanceStatusLabelMap, + rowActionList, + status +} from '../config'; import { InstanceTypeSpec, ListItem } from '../config/types'; -import tableSyles from '../styles/table.module.less'; const buildResourcesData = ( instanceType: { @@ -64,9 +69,14 @@ const formatResources = ( }; } + // VRAM = per-card GPU memory (a single card's size; not aggregated across + // cards — the model's marquee spec). + const vram = formatMemoryDisplay((instanceTypeSpec.spec as any)?.memory); + return { cpu: resources.cpu ? `${resources.cpu}C` : '-', ram: resources.ram ? `${resources.ram}GB` : '-', + vram, localStorage: record.spec?.resources?.localStorage ? `${record.spec?.resources?.localStorage}`.replace('Gi', 'GB') : undefined @@ -130,57 +140,90 @@ interface ColumnsHookProps { handleSelect: (val: string, record: ListItem) => void; clusterList: Global.BaseOption[]; sortOrder: string[]; + // name → capacity (e.g. "20Gi") for referenced persistent volumes, so the + // Disk → Persistent row can show the size instead of just the PV name. + pvCapacityByName?: Record; } const useInstancesColumns = ({ handleSelect, clusterList, - sortOrder + sortOrder, + pvCapacityByName }: ColumnsHookProps): ColumnsType => { const intl = useIntl(); const access = useAccess(); + const toGB = (v?: string) => + v ? `${v}`.replace('Gi', 'GB').replace('Mi', 'MB') : undefined; + const renderInstanceType = (record: ListItem) => { const description = parseJsonSafe(record?.description || '{}', {}).spec || {}; - const resources = formatResources({ spec: description }, record); + const accelerator = record.spec?.resources?.accelerator; + const title = description.acceleratable + ? `${description.product} x ${accelerator}` + : 'CPU'; + + const volume = (record.spec as any)?.volume; + // Spec popover grouped by category (GPU / CPU / Memory / Disk), mirroring + // the Deployments instance info icon: dark tooltip, per-category icon, + // instance name as the title. Rows with no value are dropped. + type Section = { + icon: string; + name: string; + rows: [string | null, string | undefined][]; + }; + const sections: Section[] = []; + if (description.acceleratable) { + sections.push({ + icon: 'icon-gpu', + name: 'GPU', + rows: [ + ['Count', accelerator ? `${accelerator}` : undefined], + ['Instance Type', description.product], + [ + intl.formatMessage({ id: 'gpuservice.instance.memory' }), + resources.vram + ] + ] + }); + } + sections.push({ + icon: 'icon-cpu', + name: 'CPU', + rows: [[null, resources.cpu]] + }); + sections.push({ + icon: 'icon-ram-02', + name: 'Memory', + rows: [[null, resources.ram]] + }); + sections.push({ + icon: 'icon-hard-disk', + name: intl.formatMessage({ id: 'gpuservice.instance.disk' }), + rows: [ + ['System', resources.localStorage], + // Data disk is either ephemeral OR persistent (mutually exclusive at + // create time, but each shown if present): ephemeral.capacity for a + // temp disk; persistentTemplate.spec.capacity / persistent.name for a + // persistent one (a referenced PV carries only a name, no capacity). + ['Data', toGB(volume?.ephemeral?.capacity)], + [ + 'Persistent', + // Inline-created PV carries its own capacity; a referenced PV has only + // a name, so resolve its size from the fetched PV map (fall back to + // the name if it can't be resolved). + toGB(volume?.persistentTemplate?.spec?.capacity) || + toGB(pvCapacityByName?.[volume?.persistent?.name]) || + volume?.persistent?.name + ] + ] + }); return ( - - - {description.acceleratable - ? `${description.product} x ${record.spec?.resources?.accelerator}` - : 'CPU'} - - } - > - - {description.acceleratable - ? `${description.product} x ${record.spec?.resources?.accelerator}` - : 'CPU'} - - - - {resources.cpu} - - - {intl.formatMessage({ id: 'gpuservice.instance.ram' })}:{' '} - {resources.ram} - - - - {intl.formatMessage({ id: 'gpuservice.instance.disk' })}:{' '} - {resources.localStorage} - - - + ); }; @@ -362,7 +405,7 @@ const useInstancesColumns = ({ ) } ]; - }, [handleSelect, sortOrder, clusterList, intl]); + }, [handleSelect, sortOrder, clusterList, intl, pvCapacityByName]); }; export default useInstancesColumns; diff --git a/src/pages/gpu-service/instances/index.tsx b/src/pages/gpu-service/instances/index.tsx index c816e47e..5eda2c4e 100644 --- a/src/pages/gpu-service/instances/index.tsx +++ b/src/pages/gpu-service/instances/index.tsx @@ -10,8 +10,9 @@ import { useMemoizedFn } from 'ahooks'; import { ConfigProvider, message, Modal, Table } from 'antd'; import { useSetAtom } from 'jotai'; import _ from 'lodash'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import PageBox from '../../_components/page-box'; +import { queryGPUServiceStorage } from '../storage/apis'; import { deleteGPUServiceInstance, GPU_SERVICE_INSTANCES_API, @@ -90,8 +91,29 @@ const GPUService: React.FC = () => { loading: clusterLoading } = useQueryClusterList(); + // name → capacity for persistent volumes, so the Instance Type popover can + // show the persistent disk size (the instance spec only references it by + // name). Best-effort: falls back to the name if a PV can't be resolved. + const [pvCapacityByName, setPvCapacityByName] = useState< + Record + >({}); + useEffect(() => { fetchClusterList({ page: -1 }); + (async () => { + try { + const res = await queryGPUServiceStorage({ page: -1 } as any); + const map: Record = {}; + (res?.items || []).forEach((pv: any) => { + if (pv?.name && pv?.spec?.capacity) { + map[pv.name] = pv.spec.capacity; + } + }); + setPvCapacityByName(map); + } catch { + // best-effort; the popover falls back to the PV name + } + })(); }, []); const hasK8sCluster = useMemo( @@ -199,7 +221,8 @@ const GPUService: React.FC = () => { const columns = useInstancesColumns({ handleSelect, clusterList, - sortOrder + sortOrder, + pvCapacityByName }); return (