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 { Flex } from 'antd';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { manufactureColorMap } from '../../templates/config';
|
import { manufactureColorMap } from '../../templates/config';
|
||||||
import { convertKiToGi } from '../config';
|
import { formatMemoryDisplay } from '../config';
|
||||||
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
|
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`
|
const Title = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -136,8 +133,7 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
|
|||||||
icon="icon-gpu1"
|
icon="icon-gpu1"
|
||||||
label={intl.formatMessage({ id: 'gpuservice.instance.memory' })}
|
label={intl.formatMessage({ id: 'gpuservice.instance.memory' })}
|
||||||
value={
|
value={
|
||||||
toDisplayUnit(convertKiToGi(specData?.memory ?? undefined)) ??
|
formatMemoryDisplay(specData?.memory ?? undefined) ?? '-'
|
||||||
'-'
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MetaItem
|
<MetaItem
|
||||||
|
|||||||
@@ -176,6 +176,21 @@ export const convertKiToGi = (value?: string): string | undefined => {
|
|||||||
return `${_.floor(Number(num) / GI_DIVISOR[unit], 0)} Gi`;
|
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 => {
|
const parseQuantity = (value?: string | null): number => {
|
||||||
if (!value) return 0;
|
if (!value) return 0;
|
||||||
const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value));
|
const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value));
|
||||||
|
|||||||
@@ -6,15 +6,20 @@ import {
|
|||||||
StatusTag
|
StatusTag
|
||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
import { useAccess, useIntl } from '@umijs/max';
|
import { useAccess, useIntl } from '@umijs/max';
|
||||||
import { Button, Flex } from 'antd';
|
import { Button } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/lib/table';
|
import type { ColumnsType } from 'antd/lib/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { Fragment, useMemo } from 'react';
|
import { Fragment, useMemo } from 'react';
|
||||||
import { parseJsonSafe } from '../../utils';
|
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 { InstanceTypeSpec, ListItem } from '../config/types';
|
||||||
import tableSyles from '../styles/table.module.less';
|
|
||||||
|
|
||||||
const buildResourcesData = (
|
const buildResourcesData = (
|
||||||
instanceType: {
|
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 {
|
return {
|
||||||
cpu: resources.cpu ? `${resources.cpu}C` : '-',
|
cpu: resources.cpu ? `${resources.cpu}C` : '-',
|
||||||
ram: resources.ram ? `${resources.ram}GB` : '-',
|
ram: resources.ram ? `${resources.ram}GB` : '-',
|
||||||
|
vram,
|
||||||
localStorage: record.spec?.resources?.localStorage
|
localStorage: record.spec?.resources?.localStorage
|
||||||
? `${record.spec?.resources?.localStorage}`.replace('Gi', 'GB')
|
? `${record.spec?.resources?.localStorage}`.replace('Gi', 'GB')
|
||||||
: undefined
|
: undefined
|
||||||
@@ -130,57 +140,90 @@ interface ColumnsHookProps {
|
|||||||
handleSelect: (val: string, record: ListItem) => void;
|
handleSelect: (val: string, record: ListItem) => void;
|
||||||
clusterList: Global.BaseOption<number>[];
|
clusterList: Global.BaseOption<number>[];
|
||||||
sortOrder: string[];
|
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 = ({
|
const useInstancesColumns = ({
|
||||||
handleSelect,
|
handleSelect,
|
||||||
clusterList,
|
clusterList,
|
||||||
sortOrder
|
sortOrder,
|
||||||
|
pvCapacityByName
|
||||||
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const access = useAccess();
|
const access = useAccess();
|
||||||
|
|
||||||
|
const toGB = (v?: string) =>
|
||||||
|
v ? `${v}`.replace('Gi', 'GB').replace('Mi', 'MB') : undefined;
|
||||||
|
|
||||||
const renderInstanceType = (record: ListItem) => {
|
const renderInstanceType = (record: ListItem) => {
|
||||||
const description =
|
const description =
|
||||||
parseJsonSafe<any>(record?.description || '{}', {}).spec || {};
|
parseJsonSafe<any>(record?.description || '{}', {}).spec || {};
|
||||||
|
|
||||||
const resources = formatResources({ spec: description }, record);
|
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 (
|
return (
|
||||||
<Flex align="flex-start" orientation="vertical">
|
<InstanceTypeCell title={title} name={record.name} sections={sections} />
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -362,7 +405,7 @@ const useInstancesColumns = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [handleSelect, sortOrder, clusterList, intl]);
|
}, [handleSelect, sortOrder, clusterList, intl, pvCapacityByName]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useInstancesColumns;
|
export default useInstancesColumns;
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import { useMemoizedFn } from 'ahooks';
|
|||||||
import { ConfigProvider, message, Modal, Table } from 'antd';
|
import { ConfigProvider, message, Modal, Table } from 'antd';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import PageBox from '../../_components/page-box';
|
import PageBox from '../../_components/page-box';
|
||||||
|
import { queryGPUServiceStorage } from '../storage/apis';
|
||||||
import {
|
import {
|
||||||
deleteGPUServiceInstance,
|
deleteGPUServiceInstance,
|
||||||
GPU_SERVICE_INSTANCES_API,
|
GPU_SERVICE_INSTANCES_API,
|
||||||
@@ -90,8 +91,29 @@ const GPUService: React.FC = () => {
|
|||||||
loading: clusterLoading
|
loading: clusterLoading
|
||||||
} = useQueryClusterList();
|
} = 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(() => {
|
useEffect(() => {
|
||||||
fetchClusterList({ page: -1 });
|
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(
|
const hasK8sCluster = useMemo(
|
||||||
@@ -199,7 +221,8 @@ const GPUService: React.FC = () => {
|
|||||||
const columns = useInstancesColumns({
|
const columns = useInstancesColumns({
|
||||||
handleSelect,
|
handleSelect,
|
||||||
clusterList,
|
clusterList,
|
||||||
sortOrder
|
sortOrder,
|
||||||
|
pvCapacityByName
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user