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 (