feat(usage): render instance types by actual shape (CPU spec / GPU x cards)

Instance Types are grouped by actual shape now, so each row/series is one
concrete type. Label them accordingly via a shared instanceTypeSeriesLabel,
used by both the table column and the trend legend so they read identically:
GPU shows "<product> x <cards>", CPU shows "CPU Only · N vCPU · M GB" (the
instance's real size, not the bare "CPU Only" + hover tooltip). The Instances
tab also uses the per-instance cpu/mem totals, so a 3c6g instance of a 1c2g
flavor reads 3c6g instead of 1c2g. flatten carries cpu_milli/memory_mib/
gpu_count from the breakdown dimensions.
This commit is contained in:
michelia
2026-06-30 14:11:39 +08:00
committed by jialin
parent 5e7d83e5dd
commit 457d2f2f72
3 changed files with 81 additions and 24 deletions
+17 -8
View File
@@ -11,6 +11,7 @@
* whole-machine SKU model meters runtime, not decomposed components.
*/
import { request } from '@umijs/max';
import { instanceTypeSeriesLabel } from '../utils/format-instance-type';
export interface ResourceUsageFilters {
creator_ids?: number[];
@@ -73,6 +74,10 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
unit_cpu_milli?: number;
unit_memory_mib?: number;
vram_mib?: number;
// Instance totals (requested cpu/ram) — the real size, so CPU instance types
// show "CPU Only · 2 vCPU · 4 GB" instead of just the per-unit spec.
cpu_milli?: number;
memory_mib?: number;
// Per-instance rows also carry the card count + ephemeral disk so the
// Instances table can render "<product> x <count>" + the spec popover.
gpu_count?: number;
@@ -202,6 +207,8 @@ interface ServerBreakdownItem {
unit_cpu_milli?: number | null;
unit_memory_mib?: number | null;
vram_mib?: number | null;
cpu_milli?: number | null;
memory_mib?: number | null;
gpu_count?: number | null;
ephemeral_mib?: number | null;
local_storage_mib?: number | null;
@@ -313,6 +320,8 @@ function flattenItem(
if (dims.unit_memory_mib != null)
flat.unit_memory_mib = dims.unit_memory_mib;
if (dims.vram_mib != null) flat.vram_mib = dims.vram_mib;
if (dims.cpu_milli != null) flat.cpu_milli = dims.cpu_milli;
if (dims.memory_mib != null) flat.memory_mib = dims.memory_mib;
if (dims.gpu_count != null) flat.gpu_count = dims.gpu_count;
if (dims.ephemeral_mib != null) flat.ephemeral_mib = dims.ephemeral_mib;
if (dims.local_storage_mib != null)
@@ -321,14 +330,14 @@ function flattenItem(
if (dims.storage_type) flat.storage_type = dims.storage_type;
if (dims.capacity_mib != null) flat.capacity_mib = dims.capacity_mib;
}
// For an instance-type grouped trend the series label (``group``) defaults to
// the raw flavor slug; prefer the pretty product name so the chart legend
// matches the GPU Instances list (#5700). ``groupBy`` here is the unmapped
// frontend dimension — ``gpu_type`` maps to the backend's ``instance_type``
// (see GROUP_BY_MAP). Falls back to the slug for legacy rows that predate
// dimension enrichment.
if (groupBy === 'gpu_type' && flat.product) {
flat.group = flat.product;
// Instance-type grouped trend: the series label (``group``) defaults to the
// raw flavor slug. Instance Types are grouped by actual shape, so label each
// series by that shape — "<product> x <cards>" / "CPU Only · 3 vCPU · 6 GB" —
// matching the table and keeping every shape a distinct series (#5700).
// ``groupBy`` is the unmapped frontend dimension; the instance-type axis is
// ``gpu_type`` (→ backend ``instance_type`` via GROUP_BY_MAP).
if (groupBy === 'gpu_type') {
flat.group = instanceTypeSeriesLabel(flat);
}
return flat;
}
@@ -5,7 +5,10 @@ import {
import { useIntl } from '@umijs/max';
import { useMemo } from 'react';
import { ResourceBreakdownItem } from '../../apis/resource';
import { instanceTypeLabel } from '../../utils/format-instance-type';
import {
cpuOnlyLabel,
instanceTypeSeriesLabel
} from '../../utils/format-instance-type';
import { parseRollup } from '../../utils/time-buckets';
type GroupKey = 'gpu_type' | 'instance' | 'user';
@@ -44,25 +47,29 @@ const useInstancesColumns = (groupKey: GroupKey) => {
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
dataIndex: 'gpu_type',
key: 'gpu_type',
render: (_v: string, row: ResourceBreakdownItem) =>
renderInstanceType(
render: (_v: string, row: ResourceBreakdownItem) => {
const isCpu = !row.gpu_count && !row.vram_mib;
return 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,
// CPU instance types show their real total size (cpu/mem totals);
// GPU keeps per-card specs since the renderer multiplies by the
// card count.
unitCpuMilli: isCpu ? row.cpu_milli : row.unit_cpu_milli,
unitMemoryMib: isCpu ? row.memory_mib : row.unit_memory_mib,
vramMib: row.vram_mib
}),
{
intl,
categories: ['cpu', 'ram'],
title:
!!row.gpu_count || !!row.vram_mib
? instanceTypeLabel(row)
: 'CPU Only'
// Each row is one shape: GPU "<product> x <cards>", CPU
// "CPU Only · <spec>".
title: instanceTypeSeriesLabel(row)
}
)
);
}
};
// Instances breakdown: render through the canonical GPU Instances list
// renderer so the label + spec popover are identical. The breakdown row
@@ -71,21 +78,26 @@ const useInstancesColumns = (groupKey: GroupKey) => {
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
dataIndex: 'gpu_type',
key: 'gpu_type',
render: (_v: string, row: ResourceBreakdownItem) =>
renderInstanceType(
render: (_v: string, row: ResourceBreakdownItem) => {
const isCpu = !row.gpu_count && !row.vram_mib;
return 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,
// A per-instance row is one concrete instance, so CPU shows its
// real requested size (cpu/mem totals), not the per-unit flavor
// spec — e.g. a 3c6g instance of a 1c2g flavor reads "3 vCPU · 6 GB".
unitCpuMilli: isCpu ? row.cpu_milli : row.unit_cpu_milli,
unitMemoryMib: isCpu ? row.memory_mib : row.unit_memory_mib,
vramMib: row.vram_mib,
localStorageMib: row.local_storage_mib,
ephemeralMib: row.ephemeral_mib,
persistentMib: row.persistent_mib
}),
{ intl }
)
{ intl, title: isCpu ? cpuOnlyLabel(row) : undefined }
);
}
};
// 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),
@@ -16,3 +16,39 @@ import { ResourceBreakdownItem } from '../apis/resource';
export const instanceTypeLabel = (
row?: Partial<ResourceBreakdownItem>
): string => row?.product || row?.gpu_type || '-';
const _trim = (n: number): string =>
Number.isInteger(n) ? `${n}` : n.toFixed(1);
// Compact CPU/RAM spec, e.g. "2 vCPU · 4 GB", from the instance totals
// (millicores / MiB). Empty string when neither is known.
export const formatCpuSpec = (
cpuMilli?: number | null,
memMib?: number | null
): string => {
const parts: string[] = [];
if (cpuMilli) parts.push(`${_trim(cpuMilli / 1000)} vCPU`);
if (memMib) parts.push(`${_trim(memMib / 1024)} GB`);
return parts.join(' · ');
};
// CPU instance-type label: "CPU Only" plus its real size when known, e.g.
// "CPU Only · 2 vCPU · 4 GB". Used by both the table column and the trend
// legend so they read identically.
export const cpuOnlyLabel = (row?: Partial<ResourceBreakdownItem>): string => {
const spec = formatCpuSpec(row?.cpu_milli, row?.memory_mib);
return spec ? `CPU Only · ${spec}` : 'CPU Only';
};
// Instance Types are grouped by actual shape, so each row is one concrete
// type: a GPU shows "<product> x <cards>", a CPU shows "CPU Only · <spec>".
// One label for the table column and the trend legend so they read the same
// and each shape is a distinct series. (" x " matches the GPU Instances list.)
export const instanceTypeSeriesLabel = (
row?: Partial<ResourceBreakdownItem>
): string => {
const isCpu = !row?.gpu_count && !row?.vram_mib;
if (isCpu) return cpuOnlyLabel(row);
const product = row?.product || row?.gpu_type || '-';
return row?.gpu_count ? `${product} x ${row.gpu_count}` : product;
};