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 9b3bba29..41b4502a 100644 --- a/src/pages/gpu-service/instances/components/instance-type-item.tsx +++ b/src/pages/gpu-service/instances/components/instance-type-item.tsx @@ -112,7 +112,7 @@ function getInstanceDerived(item: InstanceTypeItemModel) { acceleratable, isGPU: acceleratable, manufacturer: acceleratable ? spec.manufacturer || '' : 'cpu', // GPU manufacturer or 'cpu' for non-acceleratable types - displayName: spec.product || item.name, + displayName: acceleratable ? spec.product || item.name : 'CPU Only', ramUnit: spec.unitResourcesParsed?.ram?.value, os: _.capitalize(spec.os) || '', arch: spec.arch, 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 b60fef53..0fbed467 100644 --- a/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx +++ b/src/pages/gpu-service/instances/hooks/use-instances-columns.tsx @@ -13,15 +13,9 @@ import type { ColumnsType } from 'antd/lib/table'; import dayjs from 'dayjs'; import _ from 'lodash'; import { Fragment, useMemo } from 'react'; -import { parseJsonSafe } from '../../utils'; -import InstanceTypeCell from '../components/instance-type-cell'; -import { - formatMemoryDisplay, - InstanceStatusLabelMap, - rowActionList, - status -} from '../config'; -import { InstanceTypeSpec, ListItem } from '../config/types'; +import { InstanceStatusLabelMap, rowActionList, status } from '../config'; +import { ListItem } from '../config/types'; +import { renderInstanceType } from '../utils/render-instance-type'; const buildRowActions = (record: ListItem) => { return rowActionList @@ -37,71 +31,6 @@ const buildRowActions = (record: ListItem) => { })); }; -const toGB = (v?: string | number) => - v ? `${v}`.replace('Gi', ' GB').replace('Mi', ' MB') : '-'; - -const buildResourcesData = ( - instanceType: { - spec: InstanceTypeSpec; - }, - options: { - count: number; - } -) => { - const unitResourcesParsed = instanceType?.spec?.unitResourcesParsed; - const acceleratable = instanceType?.spec?.acceleratable; - const { count = 0 } = options; - - if (acceleratable) { - return { - accelerator: _.toString(count), - cpu: unitResourcesParsed?.cpu?.cores - ? count * unitResourcesParsed?.cpu?.cores - : undefined, - ram: unitResourcesParsed?.ram?.value - ? count * unitResourcesParsed?.ram?.value - : undefined - }; - } - return {}; -}; - -const formatResources = ( - instanceTypeSpec: { spec: InstanceTypeSpec }, - record: ListItem -) => { - const resources = buildResourcesData(instanceTypeSpec, { - count: _.toNumber(record.spec?.resources?.accelerator) || 0 - }); - - if (!record.spec?.resources?.accelerator) { - return { - cpu: record.spec?.resources?.cpu - ? `${record.spec?.resources?.cpu} vCPU` - : '-', - ram: record.spec?.resources?.ram - ? toGB(record.spec?.resources?.ram) - : '-', - localStorage: record.spec?.resources?.localStorage - ? toGB(record.spec?.resources?.localStorage) - : '-' - }; - } - - // 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} vCPU` : '-', - ram: resources.ram ? `${resources.ram} GB` : '-', - vram, - localStorage: record.spec?.resources?.localStorage - ? toGB(record.spec?.resources?.localStorage) - : undefined - }; -}; - type ConnectEntry = | { type: 'ssh'; @@ -175,81 +104,6 @@ const useInstancesColumns = ({ const pluginCols = usePluginListColumns('gpuInstances'); const creatorCols = useCreatorColumn('gpuInstances'); - 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: [ - [ - intl.formatMessage({ id: 'gpuservice.table.count' }), - accelerator ? `${accelerator}` : undefined - ], - [ - intl.formatMessage({ id: 'gpuservice.instance.section.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: intl.formatMessage({ id: 'gpuservice.instance.ram' }), - rows: [[null, resources.ram]] - }); - sections.push({ - icon: 'icon-hard-disk', - name: intl.formatMessage({ id: 'gpuservice.instance.disk' }), - rows: [ - [ - intl.formatMessage({ id: 'gpuservice.instance.disk.system' }), - resources.localStorage - ], - [ - intl.formatMessage({ id: 'gpuservice.instance.disk.ephemeral' }), - toGB(volume?.ephemeral?.capacity) - ], - [ - intl.formatMessage({ id: 'gpuservice.instance.disk.persistent' }), - toGB(volume?.persistentTemplate?.spec?.capacity) || - toGB(pvCapacityByName?.[volume?.persistent?.name]) || - volume?.persistent?.name - ] - ] - }); - - return ( - - ); - }; - return useMemo(() => { const pluginRendered = pluginCols.map((c) => ({ title: intl.formatMessage({ id: c.titleId }), @@ -389,7 +243,8 @@ const useInstancesColumns = ({ showTitle: false }, width: 300, - render: (_text: string, record: ListItem) => renderInstanceType(record) + render: (_text: string, record: ListItem) => + renderInstanceType(record, { intl, pvCapacityByName }) }, ...pluginRendered, { diff --git a/src/pages/gpu-service/instances/utils/render-instance-type.tsx b/src/pages/gpu-service/instances/utils/render-instance-type.tsx new file mode 100644 index 00000000..0336eee2 --- /dev/null +++ b/src/pages/gpu-service/instances/utils/render-instance-type.tsx @@ -0,0 +1,237 @@ +/** + * Canonical "Instance Type" cell renderer. + * + * This is the single source of truth for the Instance Type column: the GPU + * Instances list ([use-instances-columns]) and the Usage GPU Instances table + * both render through it, so the primary label + the categorized spec popover + * (GPU / CPU / Memory / Disk) stay byte-for-byte identical. + * + * It operates on the GPU-service ``ListItem`` shape. Callers whose data has a + * different shape (e.g. the Usage breakdown rows) build a minimal ``ListItem`` + * with ``buildInstanceTypeRecordFromMiB`` and feed it here. + */ +import _ from 'lodash'; +import { parseJsonSafe } from '../../utils'; +import InstanceTypeCell from '../components/instance-type-cell'; +import { formatMemoryDisplay } from '../config'; +import { InstanceTypeSpec, ListItem } from '../config/types'; + +// Minimal shape of the ``useIntl()`` result we depend on — keeps this module +// free of an intl package import. +type IntlLike = { formatMessage: (descriptor: { id: string }) => string }; + +const toGB = (v?: string | number) => + v ? `${v}`.replace('Gi', ' GB').replace('Mi', ' MB') : '-'; + +const buildResourcesData = ( + instanceType: { + spec: InstanceTypeSpec; + }, + options: { + count: number; + } +) => { + const unitResourcesParsed = instanceType?.spec?.unitResourcesParsed; + const acceleratable = instanceType?.spec?.acceleratable; + const { count = 0 } = options; + + if (acceleratable) { + return { + accelerator: _.toString(count), + cpu: unitResourcesParsed?.cpu?.cores + ? count * unitResourcesParsed?.cpu?.cores + : undefined, + ram: unitResourcesParsed?.ram?.value + ? count * unitResourcesParsed?.ram?.value + : undefined + }; + } + return {}; +}; + +const formatResources = ( + instanceTypeSpec: { spec: InstanceTypeSpec }, + record: ListItem +) => { + const resources = buildResourcesData(instanceTypeSpec, { + count: _.toNumber(record.spec?.resources?.accelerator) || 0 + }); + + if (!record.spec?.resources?.accelerator) { + return { + cpu: record.spec?.resources?.cpu + ? `${record.spec?.resources?.cpu} vCPU` + : '-', + ram: record.spec?.resources?.ram + ? toGB(record.spec?.resources?.ram) + : '-', + localStorage: record.spec?.resources?.localStorage + ? toGB(record.spec?.resources?.localStorage) + : '-' + }; + } + + // 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} vCPU` : '-', + ram: resources.ram ? `${resources.ram} GB` : '-', + vram, + localStorage: record.spec?.resources?.localStorage + ? toGB(record.spec?.resources?.localStorage) + : undefined + }; +}; + +export const renderInstanceType = ( + record: ListItem, + options: { + intl: IntlLike; + // 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 { intl, pvCapacityByName } = options; + 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 Only'; + + 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: [ + [ + intl.formatMessage({ id: 'gpuservice.table.count' }), + accelerator ? `${accelerator}` : undefined + ], + [ + intl.formatMessage({ id: 'gpuservice.instance.section.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: intl.formatMessage({ id: 'gpuservice.instance.ram' }), + rows: [[null, resources.ram]] + }); + sections.push({ + icon: 'icon-hard-disk', + name: intl.formatMessage({ id: 'gpuservice.instance.disk' }), + rows: [ + [ + intl.formatMessage({ id: 'gpuservice.instance.disk.system' }), + resources.localStorage + ], + [ + intl.formatMessage({ id: 'gpuservice.instance.disk.ephemeral' }), + toGB(volume?.ephemeral?.capacity) + ], + [ + intl.formatMessage({ id: 'gpuservice.instance.disk.persistent' }), + toGB(volume?.persistentTemplate?.spec?.capacity) || + toGB(pvCapacityByName?.[volume?.persistent?.name]) || + volume?.persistent?.name + ] + ] + }); + + return ( + + ); +}; + +// MiB → k8s "Gi" quantity string, so values that arrive as raw mebibytes (the +// Usage breakdown carries them this way) render through the same toGB path as +// the GPU Instances list — i.e. as "X GB", not "X MB". +const mibToGiQuantity = (mib?: number): string | undefined => + mib ? `${Math.round(mib / 1024)}Gi` : undefined; + +// Per-card / whole-instance metrics carried by the Usage breakdown rows. +export interface InstanceTypeMiB { + name?: string; + product?: string; + // accelerator (GPU card) count; 0/undefined → CPU-only ("CPU Only"). + gpuCount?: number; + // Per-card values. + unitCpuMilli?: number; + unitMemoryMib?: number; + vramMib?: number; + // Disk (whole instance). + localStorageMib?: number; + ephemeralMib?: number; + persistentMib?: number; +} + +// Adapt the Usage breakdown's flat MiB fields into the ``ListItem`` shape the +// canonical renderer consumes, so both tables render identically. CPU/RAM ride +// on the parsed unit-resources (per card) for accelerated rows and on +// ``spec.resources`` for CPU-only rows, matching how the list derives them. +export const buildInstanceTypeRecordFromMiB = ( + data: InstanceTypeMiB +): ListItem => { + const acceleratable = (data.gpuCount ?? 0) > 0; + return { + name: data.name, + description: JSON.stringify({ + spec: { + acceleratable, + product: data.product, + memory: data.vramMib, + unitResourcesParsed: { + cpu: data.unitCpuMilli ? { cores: data.unitCpuMilli / 1000 } : null, + ram: data.unitMemoryMib ? { value: data.unitMemoryMib / 1024 } : null + } + } + }), + spec: { + resources: { + accelerator: data.gpuCount ? `${data.gpuCount}` : null, + // Only the CPU-only branch reads cpu/ram off spec.resources. + cpu: acceleratable + ? null + : data.unitCpuMilli + ? data.unitCpuMilli / 1000 + : null, + ram: acceleratable ? null : mibToGiQuantity(data.unitMemoryMib), + localStorage: mibToGiQuantity(data.localStorageMib) ?? null + }, + volume: { + ephemeral: { capacity: mibToGiQuantity(data.ephemeralMib) }, + persistentTemplate: data.persistentMib + ? { + spec: { type: '', capacity: mibToGiQuantity(data.persistentMib)! } + } + : undefined + } + } + } as ListItem; +}; diff --git a/src/pages/usage/components/gpu-instances-tab.tsx b/src/pages/usage/components/gpu-instances-tab.tsx index af1933c7..34968c32 100644 --- a/src/pages/usage/components/gpu-instances-tab.tsx +++ b/src/pages/usage/components/gpu-instances-tab.tsx @@ -10,7 +10,10 @@ * Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints. */ import useCoolColors from '@/hooks/use-cool-colors'; -import InstanceTypeCell from '@/pages/gpu-service/instances/components/instance-type-cell'; +import { + buildInstanceTypeRecordFromMiB, + renderInstanceType +} from '@/pages/gpu-service/instances/utils/render-instance-type'; import { formatLargeNumber } from '@/utils'; import { SimpleCard } from '@gpustack/core-ui'; import { useAccess, useIntl } from '@umijs/max'; @@ -24,11 +27,7 @@ import { ResourceBreakdownResponse } from '../apis/resource'; import useResourceMeta from '../hooks/use-resource-meta'; -import { - instanceTypeLabel, - instanceTypeSections, - instanceTypeTitle -} from '../utils/format-instance-type'; +import { instanceTypeLabel } from '../utils/format-instance-type'; import { bucketKey, generateBucketRange, @@ -315,22 +314,28 @@ const GpuInstancesTab: React.FC = () => { key: 'gpu_type', render: (_v: string, row: ResourceBreakdownItem) => instanceTypeLabel(row) }; - // Instances breakdown: render exactly like the GPU Instances list — - // " x " plus the categorized spec popover behind the icon. + // Instances breakdown: render through the canonical GPU Instances list + // renderer so the label + spec popover are identical. The breakdown row + // carries flat MiB fields, so adapt it into the ListItem shape first. const instanceTypeColInstance = { title: intl.formatMessage({ id: 'usage.table.instanceType' }), dataIndex: 'gpu_type', key: 'gpu_type', - render: (_v: string, row: ResourceBreakdownItem) => ( - - ) + render: (_v: string, row: ResourceBreakdownItem) => + renderInstanceType( + buildInstanceTypeRecordFromMiB({ + name: row.instance_name, + product: row.product || row.gpu_type, + gpuCount: row.gpu_count, + unitCpuMilli: row.unit_cpu_milli, + unitMemoryMib: row.unit_memory_mib, + vramMib: row.vram_mib, + localStorageMib: row.local_storage_mib, + ephemeralMib: row.ephemeral_mib, + persistentMib: row.persistent_mib + }), + { intl } + ) }; // Last Active = the last active day. The backend sends a rollup-tz instant // with its offset; parseRollup keeps that wall clock (no browser-tz convert), @@ -511,6 +516,7 @@ const GpuInstancesTab: React.FC = () => { rowKey={(row) => `${row.gpu_type ?? ''}|${row.instance_id ?? ''}|${row.user_id ?? ''}` } + key={t.key} dataSource={tableRows} columns={tableColumns as any} onChange={(_pagination, _filters, sorter: any) => { @@ -532,6 +538,7 @@ const GpuInstancesTab: React.FC = () => { } }} pagination={{ + size: 'middle', current: tablePage, pageSize: tableData?.pagination.perPage ?? 50, total: tableData?.pagination.total ?? 0, diff --git a/src/pages/usage/utils/format-instance-type.ts b/src/pages/usage/utils/format-instance-type.ts index ec8f2740..42523f92 100644 --- a/src/pages/usage/utils/format-instance-type.ts +++ b/src/pages/usage/utils/format-instance-type.ts @@ -1,97 +1,18 @@ /** - * Instance-type display helpers — render the Usage "Instance Type" the same way - * the GPU Instances list does: a pretty product name (e.g. - * "NVIDIA-GeForce-RTX-5090-D") plus a per-card spec line, instead of the raw - * kueue flavor slug. + * Instance-type display helper for the Usage tables. * - * The product name + per-card specs ride on the breakdown rows via - * ``dimensions`` (instance-type / per-instance groupings only); older rows that - * predate the enrichment fall back to the flavor slug (``gpu_type``). + * The Instances breakdown renders its Instance Type column through the + * canonical GPU Instances renderer (``renderInstanceType`` + + * ``buildInstanceTypeRecordFromMiB``), so the label + spec popover stay + * identical to the GPU Instances list. Only the Instance Types breakdown — a + * plain product label with no popover — still uses the helper below. + * + * The product name rides on the breakdown rows via ``dimensions``; older rows + * that predate the enrichment fall back to the flavor slug (``gpu_type``). */ -import { InstanceTypeSection } from '@/pages/gpu-service/instances/components/instance-type-cell'; -import { formatMemoryDisplay } from '@/pages/gpu-service/instances/config'; import { ResourceBreakdownItem } from '../apis/resource'; // Primary label: GPU product name when known, else the flavor slug. export const instanceTypeLabel = ( row?: Partial ): string => row?.product || row?.gpu_type || '-'; - -// Round to ≤2 decimals, stripping trailing zeros, so fractional CPU allocations -// (e.g. 0.5C / 500m) aren't misrounded up to "1C". Memory uses the shared -// formatMemoryDisplay so sizes match the GPU Instances list exactly. -const fmt = (n: number): number => parseFloat(n.toFixed(2)); - -// Secondary spec line "18C · 54GB RAM · 31GB VRAM" (per card). Storage is -// intentionally excluded — it's user-customizable and not part of the type. -// Empty string when no specs are known (fall back to label only). -export const instanceTypeSpecs = ( - row?: Partial -): string => { - if (!row) return ''; - const parts: string[] = []; - if (row.unit_cpu_milli) { - parts.push(`${fmt(row.unit_cpu_milli / 1000)}C`); - } - if (row.unit_memory_mib) { - parts.push(`${formatMemoryDisplay(row.unit_memory_mib)} RAM`); - } - if (row.vram_mib) { - parts.push(`${formatMemoryDisplay(row.vram_mib)} VRAM`); - } - return parts.join(' · '); -}; - -// Per-instance title for the Instances table: " x ", matching -// the GPU Instances list (count carried in dimensions per instance). -export const instanceTypeTitle = ( - row?: Partial -): string => { - const label = instanceTypeLabel(row); - return row?.gpu_count ? `${label} x ${row.gpu_count}` : label; -}; - -// Spec-popover sections for the Instances table, fed to the shared -// InstanceTypeCell so it renders exactly like the GPU Instances list: -// GPU (Count / Instance Type / per-card VRAM), CPU + Memory as whole-instance -// totals (count × per-card, as the list shows), and the ephemeral data disk. -// Empty rows are dropped by the cell. ``labels`` carries the i18n VRAM / Disk -// captions so this util stays intl-free. -export const instanceTypeSections = ( - row: Partial | undefined, - labels: { vram: string; disk: string } -): InstanceTypeSection[] => { - if (!row) return []; - const count = row.gpu_count || 0; - const cpu = - row.unit_cpu_milli && count - ? `${fmt((row.unit_cpu_milli / 1000) * count)}C` - : undefined; - // RAM is the whole-instance total (per-card × count), as the list shows. - const ram = - row.unit_memory_mib && count - ? formatMemoryDisplay(row.unit_memory_mib * count) - : undefined; - return [ - { - icon: 'icon-gpu', - name: 'GPU', - rows: [ - ['Count', count ? `${count}` : undefined], - ['Instance Type', row.product], - [labels.vram, formatMemoryDisplay(row.vram_mib)] - ] - }, - { icon: 'icon-cpu', name: 'CPU', rows: [[null, cpu]] }, - { icon: 'icon-ram-02', name: 'Memory', rows: [[null, ram]] }, - { - icon: 'icon-hard-disk', - name: labels.disk, - rows: [ - ['System', formatMemoryDisplay(row.local_storage_mib)], - ['Data', formatMemoryDisplay(row.ephemeral_mib)], - ['Persistent', formatMemoryDisplay(row.persistent_mib)] - ] - } - ]; -};