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
("<product> x <count>") 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.
This commit is contained in:
michelia
2026-06-04 19:52:52 +08:00
committed by jialin
parent 4c5d42cb13
commit 50601b0aff
5 changed files with 214 additions and 48 deletions
@@ -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 = (
<div style={{ minWidth: 200 }}>
{name && <div style={{ fontWeight: 600, marginBottom: 8 }}>{name}</div>}
{sections.map((sec) => {
const rows = sec.rows.filter(([, v]) => v && v !== '-');
if (!rows.length) return null;
return (
<div
key={sec.name}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 16,
marginBottom: 6
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
minWidth: 88,
lineHeight: '22px'
}}
>
<IconFont type={sec.icon} />
<span>{sec.name}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{rows.map(([label, value], i) => (
<div
key={i}
style={{ display: 'flex', gap: 16, lineHeight: '22px' }}
>
<span style={{ opacity: 0.65, minWidth: 96 }}>
{label || ''}
</span>
<span>{value}</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
return (
<Flex align="center" style={{ gap: 6 }}>
<AutoTooltip ghost title={<span>{title}</span>}>
<span className="text-primary">{title}</span>
</AutoTooltip>
<Tooltip
title={specInfo}
styles={{ container: { width: 'max-content', maxWidth: 480 } }}
>
<InfoCircleOutlined
style={{ color: 'var(--ant-color-primary)', cursor: 'pointer' }}
/>
</Tooltip>
</Flex>
);
};
export default InstanceTypeCell;
@@ -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<InstanceTypeItemProps> = ({ item }) => {
icon="icon-gpu1"
label={intl.formatMessage({ id: 'gpuservice.instance.memory' })}
value={
toDisplayUnit(convertKiToGi(specData?.memory ?? undefined)) ??
'-'
formatMemoryDisplay(specData?.memory ?? undefined) ?? '-'
}
/>
<MetaItem
@@ -176,6 +176,21 @@ export const convertKiToGi = (value?: string): string | 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));
@@ -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<number>[];
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<string, string>;
}
const useInstancesColumns = ({
handleSelect,
clusterList,
sortOrder
sortOrder,
pvCapacityByName
}: ColumnsHookProps): ColumnsType<ListItem> => {
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<any>(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 (
<Flex align="flex-start" orientation="vertical">
<AutoTooltip
ghost
title={
<span>
{description.acceleratable
? `${description.product} x ${record.spec?.resources?.accelerator}`
: 'CPU'}
</span>
}
>
<span className="text-primary">
{description.acceleratable
? `${description.product} x ${record.spec?.resources?.accelerator}`
: 'CPU'}
</span>
</AutoTooltip>
<Flex
align="center"
style={{ fontSize: 13, color: 'var(--ant-color-text-tertiary)' }}
>
<span>{resources.cpu}</span>
<span className={tableSyles.dot} />
<span>
{intl.formatMessage({ id: 'gpuservice.instance.ram' })}:{' '}
{resources.ram}
</span>
<span className={tableSyles.dot} />
<span>
{intl.formatMessage({ id: 'gpuservice.instance.disk' })}:{' '}
{resources.localStorage}
</span>
</Flex>
</Flex>
<InstanceTypeCell title={title} name={record.name} sections={sections} />
);
};
@@ -362,7 +405,7 @@ const useInstancesColumns = ({
)
}
];
}, [handleSelect, sortOrder, clusterList, intl]);
}, [handleSelect, sortOrder, clusterList, intl, pvCapacityByName]);
};
export default useInstancesColumns;
+25 -2
View File
@@ -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<string, string>
>({});
useEffect(() => {
fetchClusterList({ page: -1 });
(async () => {
try {
const res = await queryGPUServiceStorage({ page: -1 } as any);
const map: Record<string, string> = {};
(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 (