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:
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user