From 62f34ffcaf8a4b3569d6bb6dfbcabaa0014ab180 Mon Sep 17 00:00:00 2001 From: michelia Date: Thu, 4 Jun 2026 18:22:59 +0800 Subject: [PATCH] feat(usage): resource usage metering page Add the Usage page with Summary / Tokens / GPU Instances / Storage / Resource Events tabs over the new metering endpoints: per-resource breakdowns with date / scope / user / resource filters, trend charts, server-side sortable tables (GPU-Hours, Instance-Hours, GB-Days, GB-Hours), Excel export with an in-dialog preview, and KPI cards with help tooltips explaining each metric. MaaS-only users (no Kubernetes cluster and no resource events) get a tokens-only view with the tab bar dropped; GPU Service / the full page unlock for admins, cluster owners, or anyone who has run a resource. Instance-type rows reuse the GPU Instances list styling, and deleted users / instances / volumes are flagged in breakdowns and filters. --- src/access.ts | 8 +- src/app.tsx | 46 ++++++++- src/pages/_components/pie-chart/index.tsx | 7 ++ src/pages/usage/apis/resource.ts | 61 +++++++++++- src/pages/usage/components/export-data.tsx | 26 ++++- .../usage/components/gpu-instances-tab.tsx | 88 +++++++++++++++-- src/pages/usage/components/metric-label.tsx | 25 +++++ .../usage/components/resource-events.tsx | 48 +++++---- .../usage/components/resource-filter-bar.tsx | 9 ++ src/pages/usage/components/storage-tab.tsx | 74 +++++++++++++- src/pages/usage/components/summary-tab.tsx | 4 + src/pages/usage/hooks/use-resource-meta.ts | 3 +- src/pages/usage/index.tsx | 8 ++ src/pages/usage/utils/format-instance-type.ts | 97 +++++++++++++++++++ 14 files changed, 459 insertions(+), 45 deletions(-) create mode 100644 src/pages/usage/components/metric-label.tsx create mode 100644 src/pages/usage/utils/format-instance-type.ts diff --git a/src/access.ts b/src/access.ts index 0c411a49..03b7e5ac 100644 --- a/src/access.ts +++ b/src/access.ts @@ -3,6 +3,7 @@ import { applyAccessExtensions } from './access.extensions'; export default (initialState: { currentUser?: Global.UserInfo; hasKubernetesCluster?: boolean; + hasResourceEvents?: boolean; }) => { const isPlatformAdmin = !!( initialState && @@ -20,6 +21,10 @@ export default (initialState: { // role-based default so a transient network blip can't lock anyone // out of the menu. const hasKubernetesCluster = initialState?.hasKubernetesCluster; + // Having run GPU/CPU instances or storage (any resource_events) also unlocks + // GPU Service / the full Usage page — a user who used it keeps seeing it even + // without a current cluster. MaaS-only users (no cluster, no events) don't. + const hasResourceEvents = !!initialState?.hasResourceEvents; // Predicate roles, top-down by strictness: // * `canSeeAdmin` — strictly platform admin (`users.is_admin`). @@ -41,7 +46,8 @@ export default (initialState: { return applyAccessExtensions({ canSeeAdmin: isPlatformAdmin, canSeeOrgAdmin: isPlatformAdmin, - canSeeGpuService: isPlatformAdmin || hasKubernetesCluster !== false, + canSeeGpuService: + isPlatformAdmin || hasKubernetesCluster !== false || hasResourceEvents, canManageCurrentOrg: false, canSeeUser, canDelete: true, diff --git a/src/app.tsx b/src/app.tsx index 3ec0c224..200940d8 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -5,6 +5,7 @@ import { DEFAULT_ENTER_PAGE, GPUSTACK_API_BASE_URL } from '@/config/settings'; import { COLOR_PRIMARY } from '@/config/theme/constants'; import { queryClusterList } from '@/pages/cluster-management/apis'; import { ProviderValueMap } from '@/pages/cluster-management/config'; +import { queryResourceEvents } from '@/pages/usage/apis/resource'; import { enterprisePluginReady } from '@/plugins/enterprise-ready'; import { GPUStackPluginManager } from '@/plugins/manager'; import { requestConfig } from '@/request-config'; @@ -72,12 +73,44 @@ const probeHasKubernetesCluster = async (): Promise => { } }; +// Probes whether the caller has ANY resource-usage events (GPU/CPU instance or +// storage lifecycle). Used alongside the cluster probe so a user who has run +// GPU instances still sees GPU Service / the full Usage page even if they +// currently have no Kubernetes cluster. Mirrored into sessionStorage for the +// access extensions; any failure → undefined ("unknown — don't restrict"). +const HAS_RESOURCE_EVENTS_KEY = 'hasResourceEvents'; +const probeHasResourceEvents = async (): Promise => { + try { + // No date range = "ever"; scope is clamped to the caller server-side. + const res = await queryResourceEvents({ perPage: 1 }); + const value = (res?.pagination?.total ?? 0) > 0; + try { + window.sessionStorage.setItem( + HAS_RESOURCE_EVENTS_KEY, + JSON.stringify(value) + ); + } catch { + // sessionStorage may be unavailable; predicate treats missing as unknown. + } + return value; + } catch (error) { + console.error('probeHasResourceEvents error', error); + try { + window.sessionStorage.removeItem(HAS_RESOURCE_EVENTS_KEY); + } catch { + // ignore + } + return undefined; + } +}; + // runtime configuration export async function getInitialState(): Promise<{ fetchUserInfo: () => Promise; currentUser?: Global.UserInfo; pluginData?: Record; hasKubernetesCluster?: boolean; + hasResourceEvents?: boolean; }> { const { location } = history; @@ -161,16 +194,19 @@ export async function getInitialState(): Promise<{ getAppVersionInfo(); if (![DEFAULT_ENTER_PAGE.login].includes(location.pathname)) { - const [userInfo, hasKubernetesCluster] = await Promise.all([ - fetchUserInfo(), - probeHasKubernetesCluster() - ]); + const [userInfo, hasKubernetesCluster, hasResourceEvents] = + await Promise.all([ + fetchUserInfo(), + probeHasKubernetesCluster(), + probeHasResourceEvents() + ]); checkDefaultPage(userInfo); return { fetchUserInfo, currentUser: userInfo, pluginData, - hasKubernetesCluster + hasKubernetesCluster, + hasResourceEvents }; } return { diff --git a/src/pages/_components/pie-chart/index.tsx b/src/pages/_components/pie-chart/index.tsx index 6ec30c2f..26f6001a 100644 --- a/src/pages/_components/pie-chart/index.tsx +++ b/src/pages/_components/pie-chart/index.tsx @@ -47,6 +47,10 @@ const PieChart: React.FC = ({ color: colors, tooltip: { trigger: 'item', + // Keep the tooltip inside the chart box — the donut sits on the left, so + // a left-slice tooltip could otherwise spill out and get hidden behind + // the side menu. + confine: true, backgroundColor: token.colorBgElevated, borderColor: 'transparent', formatter: (params: any) => { @@ -69,6 +73,9 @@ const PieChart: React.FC = ({ itemWidth: 8, itemHeight: 8, itemGap: 10, + // Long names are truncated in the legend; hovering shows the full name + // in a tooltip. + tooltip: { show: true }, textStyle: { color: token.colorTextTertiary, overflow: 'truncate', diff --git a/src/pages/usage/apis/resource.ts b/src/pages/usage/apis/resource.ts index a42c9d61..b299578c 100644 --- a/src/pages/usage/apis/resource.ts +++ b/src/pages/usage/apis/resource.ts @@ -35,6 +35,10 @@ export interface ResourceBreakdownRequest { | 'volume' | null; granularity?: 'hour' | 'day' | 'week' | 'month'; + // Server-side sort: a metric key (e.g. gpu_hours / instance_hours) + + // direction. Defaults on the server when omitted. + order_by?: string; + descending?: boolean; page?: number; perPage?: number; } @@ -66,6 +70,21 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary { user_id?: number; user_name?: string; last_active?: string; + // Instance-type rows carry the flavor's display fields (pretty product name + + // per-card specs) so the UI matches the GPU Instances list. + product?: string; + unit_cpu_milli?: number; + unit_memory_mib?: number; + vram_mib?: number; + // Per-instance rows also carry the card count + ephemeral disk so the + // Instances table can render " x " + the spec popover. + gpu_count?: number; + ephemeral_mib?: number; + local_storage_mib?: number; + persistent_mib?: number; + // Storage volume rows: provisioned capacity + storage type. + storage_type?: string; + capacity_mib?: number; } export interface ResourceBreakdownResponse { @@ -117,6 +136,8 @@ export interface ResourceEventItem { event_type: string; event_message?: string; phase?: string; + // status.phaseMessage at event time — the detail behind a failure phase. + phase_message?: string; } export interface ResourceEventsResponse { @@ -179,6 +200,18 @@ interface ServerBreakdownItem { date?: string | null; sku?: string | null; deleted?: boolean | null; + dimensions?: { + product?: string | null; + unit_cpu_milli?: number | null; + unit_memory_mib?: number | null; + vram_mib?: number | null; + gpu_count?: number | null; + ephemeral_mib?: number | null; + local_storage_mib?: number | null; + persistent_mib?: number | null; + storage_type?: string | null; + capacity_mib?: number | null; + } | null; metrics: ServerMetrics; } @@ -271,6 +304,23 @@ function flattenItem( if (!flat.gpu_type && it.sku) { flat.gpu_type = it.sku ?? undefined; } + // Instance-type rows carry flavor display fields (pretty product + per-card + // specs) so the UI can render them like the GPU Instances list. + const dims = it.dimensions; + if (dims) { + if (dims.product) flat.product = dims.product; + if (dims.unit_cpu_milli != null) flat.unit_cpu_milli = dims.unit_cpu_milli; + 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.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) + flat.local_storage_mib = dims.local_storage_mib; + if (dims.persistent_mib != null) flat.persistent_mib = dims.persistent_mib; + if (dims.storage_type) flat.storage_type = dims.storage_type; + if (dims.capacity_mib != null) flat.capacity_mib = dims.capacity_mib; + } return flat; } @@ -301,6 +351,8 @@ function toServerRequest(data: ResourceBreakdownRequest) { ...(creator_ids?.length ? { creator_ids } : {}), ...(instance_ids?.length ? { instance_ids } : {}), ...(volume_ids?.length ? { volume_ids } : {}), + ...(data.order_by ? { order_by: data.order_by } : {}), + ...(data.descending !== undefined ? { descending: data.descending } : {}), page: data.page ?? 1, perPage: data.perPage ?? 20 }, @@ -341,8 +393,8 @@ export async function queryStorageBreakdown( } export async function queryResourceEvents(data: { - start_date: string; - end_date: string; + start_date?: string; + end_date?: string; scope?: 'self' | 'all'; filters?: ResourceUsageFilters; resource_types?: string[]; @@ -370,6 +422,7 @@ export async function queryResourceEvents(data: { export interface ResourceFilterOption { id: number; label: string; + deleted?: boolean; } export interface ResourceFilterMeta { @@ -435,7 +488,9 @@ export async function queryUsageSummary(params: { distribution = byType.items .filter((i) => (i.gpu_hours || 0) > 0) .map((i) => ({ - label: i.gpu_type || 'unknown', + // Pretty product name (e.g. "NVIDIA-GeForce-RTX-5090-D") when known, + // else the raw flavor slug — matches the GPU Instances list. + label: i.product || i.gpu_type || 'unknown', value: i.gpu_hours, percentage: total > 0 ? (i.gpu_hours / total) * 100 : 0 })); diff --git a/src/pages/usage/components/export-data.tsx b/src/pages/usage/components/export-data.tsx index 8300f79b..4441e81f 100644 --- a/src/pages/usage/components/export-data.tsx +++ b/src/pages/usage/components/export-data.tsx @@ -50,6 +50,14 @@ const ExportData: React.FC<{ } = props || {}; const intl = useIntl(); + // Members are forced to self scope, where the backend forbids grouping by + // user (privacy) — including it 403s the export request. Drop the user + // dimension (and its column) when we can't group by it. + const canGroupByUser = initialScope !== 'self'; + const exportGroupBy = canGroupByUser + ? ['date', 'user', 'route', 'api_key'] + : ['date', 'route', 'api_key']; + const [pageParams, setPageParams] = React.useState<{ page: number; perPage: number; @@ -84,7 +92,7 @@ const ExportData: React.FC<{ ...pageParams, granularity: 'day', sort_by: '-date', - group_by: ['date', 'user', 'route', 'api_key'], + group_by: exportGroupBy, filters: nextFilters, scope: initialScope, start_date: nextCommonFilters.start_date, @@ -192,6 +200,14 @@ const ExportData: React.FC<{ } ]; + // Hide the User column when we can't group by user (self scope) — it'd be + // empty otherwise. + const visibleColumns = canGroupByUser + ? exportTableColumns + : exportTableColumns.filter( + (c) => !(Array.isArray(c.dataIndex) && c.dataIndex[0] === 'user') + ); + const handleSubmit = () => { exportJsonToExcel({ fileName: `usage_export_${commonFilters.start_date}_${commonFilters.end_date}.xlsx`, @@ -211,7 +227,7 @@ const ExportData: React.FC<{ sheetName: 'usage', fields: [ 'date', - 'user', + ...(canGroupByUser ? ['user'] : []), 'route', 'api_key', 'input_tokens', @@ -252,7 +268,7 @@ const ExportData: React.FC<{ page, perPage: pageSize, granularity: 'day', - group_by: ['date', 'user', 'model', 'api_key'], + group_by: exportGroupBy, filters, sort_by: '-date', scope: initialScope, @@ -271,7 +287,7 @@ const ExportData: React.FC<{ fetchExportData({ ...INITIAL_PAGE_PARAMS, granularity: 'day', - group_by: ['date', 'user', 'route', 'api_key'], + group_by: exportGroupBy, filters, sort_by: '-date', scope: initialScope, @@ -323,7 +339,7 @@ const ExportData: React.FC<{ > { const access = useAccess(); + const intl = useIntl(); // ``useCoolColors`` returns a memoized factory; resolve it once into a // fixed 5-slot palette here so the rest of the component reads as // array access. @@ -85,6 +93,11 @@ const GpuInstancesTab: React.FC = () => { null ); const [tablePage, setTablePage] = useState(1); + // Server-side sort for the bottom tables; default GPU Hours, descending. + const [tableSort, setTableSort] = useState<{ + field: Metric; + order: 'ascend' | 'descend'; + }>({ field: 'gpu_hours', order: 'descend' }); const baseRequest = (): Omit => ({ start_date: dateRange[0].format('YYYY-MM-DD'), @@ -122,7 +135,9 @@ const GpuInstancesTab: React.FC = () => { const data = await queryGpuInstancesBreakdown({ ...baseRequest(), group_by: activeTableTab, - page: tablePage + page: tablePage, + order_by: tableSort.field, + descending: tableSort.order === 'descend' }); setTableData(data); } catch { @@ -142,6 +157,7 @@ const GpuInstancesTab: React.FC = () => { selectedInstances, activeTableTab, tablePage, + tableSort, refreshKey ]); @@ -154,14 +170,24 @@ const GpuInstancesTab: React.FC = () => { label: formatLargeNumber( Math.round((summary?.gpu_hours ?? 0) * 10) / 10 ) as string, - value: 'GPU Hours', + value: ( + + ), color: coolColors[0] }, { label: formatLargeNumber( Math.round((summary?.instance_hours ?? 0) * 10) / 10 ) as string, - value: 'Instance Hours', + value: ( + + ), color: coolColors[1] }, { @@ -215,18 +241,48 @@ const GpuInstancesTab: React.FC = () => { title: 'GPU Hours', dataIndex: 'gpu_hours', key: 'gpu_hours', + sorter: true, + sortOrder: tableSort.field === 'gpu_hours' ? tableSort.order : null, render: (v: number) => (v ?? 0).toFixed(2) }, { title: 'Instance Hours', dataIndex: 'instance_hours', key: 'instance_hours', + sorter: true, + sortOrder: + tableSort.field === 'instance_hours' ? tableSort.order : null, render: (v: number) => (v ?? 0).toFixed(2) } ]; + // Instance Types breakdown: just the pretty product name (or flavor slug + // for older rows) — no spec sub-line. + const instanceTypeColType = { + title: 'Instance Type', + dataIndex: 'gpu_type', + 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. + const instanceTypeColInstance = { + title: 'Instance Type', + dataIndex: 'gpu_type', + key: 'gpu_type', + render: (_v: string, row: ResourceBreakdownItem) => ( + + ) + }; if (activeTableTab === 'gpu_type') { return [ - { title: 'Instance Type', dataIndex: 'gpu_type', key: 'gpu_type' }, + instanceTypeColType, ...baseValueCols, { title: 'Active Instances', @@ -239,7 +295,7 @@ const GpuInstancesTab: React.FC = () => { if (activeTableTab === 'instance') { return [ { title: 'Instance', dataIndex: 'instance_name', key: 'instance_name' }, - { title: 'Instance Type', dataIndex: 'gpu_type', key: 'gpu_type' }, + instanceTypeColInstance, ...baseValueCols, { title: 'Last Active', dataIndex: 'last_active', key: 'last_active' } ]; @@ -250,7 +306,7 @@ const GpuInstancesTab: React.FC = () => { ...baseValueCols, { title: 'Last Active', dataIndex: 'last_active', key: 'last_active' } ]; - }, [activeTableTab]); + }, [activeTableTab, tableSort, intl]); const tableRows: ResourceBreakdownItem[] = tableData?.items ?? []; @@ -377,6 +433,24 @@ const GpuInstancesTab: React.FC = () => { } dataSource={tableRows} columns={tableColumns as any} + onChange={(_pagination, _filters, sorter: any) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + // Sort changed: reset to page 1. Cleared (3rd click) → default + // back to GPU Hours descending. + const next = s?.order + ? { + field: (s.columnKey as Metric) ?? 'gpu_hours', + order: s.order as 'ascend' | 'descend' + } + : { field: 'gpu_hours' as Metric, order: 'descend' as const }; + if ( + next.field !== tableSort.field || + next.order !== tableSort.order + ) { + setTableSort(next); + setTablePage(1); + } + }} pagination={{ current: tablePage, pageSize: tableData?.pagination.perPage ?? 50, diff --git a/src/pages/usage/components/metric-label.tsx b/src/pages/usage/components/metric-label.tsx new file mode 100644 index 00000000..c7381b0b --- /dev/null +++ b/src/pages/usage/components/metric-label.tsx @@ -0,0 +1,25 @@ +import { QuestionCircleOutlined } from '@ant-design/icons'; +import { Tooltip } from 'antd'; +import React from 'react'; + +/** + * KPI card caption with a trailing help icon. The metering metrics + * (GPU-Hours vs Instance-Hours, GB-Days vs GB-Hours) aren't self-evident, so + * each label carries a one-line explanation behind the standard question-mark. + */ +const MetricLabel: React.FC<{ + text: string; + tooltip: React.ReactNode; +}> = ({ text, tooltip }) => ( + + {text} + + + + +); + +export default MetricLabel; diff --git a/src/pages/usage/components/resource-events.tsx b/src/pages/usage/components/resource-events.tsx index 5569ed12..ce6c62fd 100644 --- a/src/pages/usage/components/resource-events.tsx +++ b/src/pages/usage/components/resource-events.tsx @@ -20,9 +20,16 @@ import { import useResourceMeta from '../hooks/use-resource-meta'; import ResourceFilterBar from './resource-filter-bar'; +// Users only ever see "Storage" in the product — never "Persistent Volume". +const RESOURCE_TYPE_LABELS: Record = { + gpu_instance: 'GPU Instance', + cpu_instance: 'CPU Instance', + persistent_volume: 'Storage' +}; + const RESOURCE_TYPE_OPTIONS = [ - { value: 'gpu_instance', label: 'GPU Instance' }, - { value: 'persistent_volume', label: 'Persistent Volume' } + { value: 'gpu_instance', label: RESOURCE_TYPE_LABELS.gpu_instance }, + { value: 'persistent_volume', label: RESOURCE_TYPE_LABELS.persistent_volume } ]; const EVENT_TYPE_OPTIONS = [ @@ -53,6 +60,13 @@ const EVENT_LABEL: Record = Object.fromEntries( EVENT_TYPE_OPTIONS.map((o) => [o.value, o.label]) ); +// Humanize a failure phase enum for display, e.g. "SSHPublicKeyCreateFailed" → +// "SSH Public Key Create Failed" (fallback when the backend has no detail). +const humanizePhase = (phase: string): string => + phase + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/([a-z\d])([A-Z])/g, '$1 $2'); + const ResourceEvents: React.FC = () => { const access = useAccess(); const intl = useIntl(); @@ -110,8 +124,7 @@ const ResourceEvents: React.FC = () => { title: 'Resource', dataIndex: 'resource_type', key: 'resource_type', - render: (v: string) => - v === 'gpu_instance' ? 'GPU Instance' : 'Persistent Volume', + render: (v: string) => RESOURCE_TYPE_LABELS[v] || v, width: 160 }, { @@ -128,25 +141,22 @@ const ResourceEvents: React.FC = () => { ), width: 180 }, - { - title: 'Phase', - dataIndex: 'phase', - key: 'phase', - width: 140 - }, - { - title: 'Creator', - dataIndex: 'creator_name', - key: 'creator_name', - render: (v?: string, row?: ResourceEventItem) => - v ?? (row?.creator_id ? `principal:${row.creator_id}` : '-'), - width: 160 - }, { title: 'Message', dataIndex: 'event_message', key: 'event_message', - render: (v?: string) => v ?? '-' + render: (v?: string, row?: ResourceEventItem) => { + // A failure phase (…Failed) is the one thing not already shown in the + // Event column — surface it (with its detail) as an error message. + if (row?.phase && /failed$/i.test(row.phase)) { + return ( + + {row.phase_message || humanizePhase(row.phase)} + + ); + } + return v ?? '-'; + } } ], [] diff --git a/src/pages/usage/components/resource-filter-bar.tsx b/src/pages/usage/components/resource-filter-bar.tsx index 436daa44..cb528905 100644 --- a/src/pages/usage/components/resource-filter-bar.tsx +++ b/src/pages/usage/components/resource-filter-bar.tsx @@ -26,6 +26,7 @@ const DefaultDateConfig = { interface SelectOption { value: number; label: string; + deleted?: boolean; } // Optional per-tab entity filter (GPU instance on the GPU tab / volume on the @@ -139,6 +140,14 @@ const ResourceFilterBar: React.FC = (props) => { const userOptionRender = (option: any) => ( {option?.data?.label} + {option?.data?.deleted && ( + + [{intl.formatMessage({ id: 'usage.table.deleted' })}] + + )} ); diff --git a/src/pages/usage/components/storage-tab.tsx b/src/pages/usage/components/storage-tab.tsx index e99204a7..fffa0718 100644 --- a/src/pages/usage/components/storage-tab.tsx +++ b/src/pages/usage/components/storage-tab.tsx @@ -31,6 +31,7 @@ import { Granularity } from '../utils/time-buckets'; import MetricChartCard from './metric-chart-card'; +import MetricLabel from './metric-label'; import ResourceExportData from './resource-export-data'; import ResourceFilterBar from './resource-filter-bar'; @@ -78,6 +79,11 @@ const StorageTab: React.FC = () => { null ); const [tablePage, setTablePage] = useState(1); + // Server-side sort for the bottom tables; default GB-Days, descending. + const [tableSort, setTableSort] = useState<{ + field: Metric; + order: 'ascend' | 'descend'; + }>({ field: 'storage_gb_days', order: 'descend' }); const baseRequest = (): Omit => ({ start_date: dateRange[0].format('YYYY-MM-DD'), @@ -107,12 +113,21 @@ const StorageTab: React.FC = () => { } }; + // The frontend metric keys (storage_gb_days/hours) map to the server's + // breakdown metric keys (gb_days/gb_hours) for order_by. + const ORDER_BY_KEY: Record = { + storage_gb_days: 'gb_days', + storage_gb_hours: 'gb_hours' + }; + const fetchTable = async () => { try { const data = await queryStorageBreakdown({ ...baseRequest(), group_by: activeTableTab, - page: tablePage + page: tablePage, + order_by: ORDER_BY_KEY[tableSort.field], + descending: tableSort.order === 'descend' }); setTableData(data); } catch { @@ -132,6 +147,7 @@ const StorageTab: React.FC = () => { selectedVolumes, activeTableTab, tablePage, + tableSort, refreshKey ]); @@ -142,14 +158,24 @@ const StorageTab: React.FC = () => { label: formatLargeNumber( Math.round((summary?.storage_gb_days ?? 0) * 10) / 10 ) as string, - value: 'GB-Days', + value: ( + + ), color: coolColors[0] }, { label: formatLargeNumber( Math.round((summary?.storage_gb_hours ?? 0) * 10) / 10 ) as string, - value: 'GB-Hours', + value: ( + + ), color: coolColors[1] }, { @@ -200,12 +226,18 @@ const StorageTab: React.FC = () => { title: 'GB-Days', dataIndex: 'storage_gb_days', key: 'storage_gb_days', + sorter: true, + sortOrder: + tableSort.field === 'storage_gb_days' ? tableSort.order : null, render: (v: number) => (v ?? 0).toFixed(2) }, { title: 'GB-Hours', dataIndex: 'storage_gb_hours', key: 'storage_gb_hours', + sorter: true, + sortOrder: + tableSort.field === 'storage_gb_hours' ? tableSort.order : null, render: (v: number) => (v ?? 0).toFixed(2) } ]; @@ -216,6 +248,19 @@ const StorageTab: React.FC = () => { dataIndex: 'volume_name', key: 'volume_name' }, + { + title: 'Type', + dataIndex: 'storage_type', + key: 'storage_type', + render: (_v: string, row: ResourceBreakdownItem) => + row.storage_type || row.gpu_type || '-' + }, + { + title: 'Capacity', + dataIndex: 'capacity_mib', + key: 'capacity_mib', + render: (v?: number) => (v ? `${Math.round(v / 1024)}GB` : '-') + }, ...valueCols, { title: 'Last Active', dataIndex: 'last_active', key: 'last_active' } ]; @@ -230,7 +275,7 @@ const StorageTab: React.FC = () => { }, { title: 'Last Active', dataIndex: 'last_active', key: 'last_active' } ]; - }, [activeTableTab]); + }, [activeTableTab, tableSort]); const tableRows: ResourceBreakdownItem[] = tableData?.items ?? []; @@ -353,6 +398,27 @@ const StorageTab: React.FC = () => { } dataSource={tableRows} columns={tableColumns as any} + onChange={(_pagination, _filters, sorter: any) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + // Sort changed → page 1; cleared (3rd click) → default GB-Days + // descending. + const next = s?.order + ? { + field: (s.columnKey as Metric) ?? 'storage_gb_days', + order: s.order as 'ascend' | 'descend' + } + : { + field: 'storage_gb_days' as Metric, + order: 'descend' as const + }; + if ( + next.field !== tableSort.field || + next.order !== tableSort.order + ) { + setTableSort(next); + setTablePage(1); + } + }} pagination={{ current: tablePage, pageSize: tableData?.pagination.perPage ?? 50, diff --git a/src/pages/usage/components/summary-tab.tsx b/src/pages/usage/components/summary-tab.tsx index 18ce0e22..ff7b6229 100644 --- a/src/pages/usage/components/summary-tab.tsx +++ b/src/pages/usage/components/summary-tab.tsx @@ -423,6 +423,10 @@ const SummaryTab: React.FC = () => { headline={ <> + - items.map((i) => ({ value: i.id, label: i.label })); + items.map((i) => ({ value: i.id, label: i.label, deleted: i.deleted })); /** * Loads the resource tabs' filter dropdown sources in one call: diff --git a/src/pages/usage/index.tsx b/src/pages/usage/index.tsx index ff0427cf..0d68e8e6 100644 --- a/src/pages/usage/index.tsx +++ b/src/pages/usage/index.tsx @@ -13,6 +13,7 @@ * The previous implementation lived in this file; it now lives in * ``components/token-tab.tsx`` so we can host it as a tab pane. */ +import { useAccess } from '@umijs/max'; import { Tabs, TabsProps } from 'antd'; import React, { useMemo, useState } from 'react'; import GpuInstancesTab from './components/gpu-instances-tab'; @@ -22,6 +23,7 @@ import SummaryTab from './components/summary-tab'; import TokenTab from './components/token-tab'; const Usage: React.FC = () => { + const access = useAccess(); // Land on the cross-resource Summary by default. const [activeKey, setActiveKey] = useState('summary'); @@ -56,6 +58,12 @@ const Usage: React.FC = () => { [] ); + // Users who can't see GPU Service (MaaS-only: no cluster, no resource usage) + // only have token usage — drop the tab shell and show Tokens directly. + if (!access.canSeeGpuService) { + return ; + } + return ( +): 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)] + ] + } + ]; +};