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.
This commit is contained in:
michelia
2026-06-04 19:52:52 +08:00
committed by jialin
parent 50601b0aff
commit 62f34ffcaf
14 changed files with 459 additions and 45 deletions
+21 -5
View File
@@ -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<{
></FilterBar>
</div>
<Table
columns={exportTableColumns}
columns={visibleColumns}
className={'scroll-table'}
tableLayout={'auto'}
style={{ width: '100%', marginTop: '16px', minHeight: 400 }}
@@ -10,9 +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 { formatLargeNumber } from '@/utils';
import { SimpleCard } from '@gpustack/core-ui';
import { useAccess } from '@umijs/max';
import { useAccess, useIntl } from '@umijs/max';
import { Table, Tabs } from 'antd';
import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react';
@@ -23,12 +24,18 @@ import {
ResourceBreakdownResponse
} from '../apis/resource';
import useResourceMeta from '../hooks/use-resource-meta';
import {
instanceTypeLabel,
instanceTypeSections,
instanceTypeTitle
} from '../utils/format-instance-type';
import {
bucketKey,
generateBucketRange,
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';
@@ -49,6 +56,7 @@ const TABLE_TABS: { key: GroupKey; label: string }[] = [
const GpuInstancesTab: 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<ResourceBreakdownRequest, 'group_by'> => ({
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: (
<MetricLabel
text="GPU Hours"
tooltip="Instance running time weighted by GPU count: an instance with N GPUs running for H hours counts as N × H GPU-hours. Equal to Instance Hours when every instance uses a single GPU."
/>
),
color: coolColors[0]
},
{
label: formatLargeNumber(
Math.round((summary?.instance_hours ?? 0) * 10) / 10
) as string,
value: 'Instance Hours',
value: (
<MetricLabel
text="Instance Hours"
tooltip="Total running time summed across all instances, regardless of how many GPUs each uses. One instance running for 2 hours = 2 instance-hours."
/>
),
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 —
// "<product> x <count>" plus the categorized spec popover behind the icon.
const instanceTypeColInstance = {
title: 'Instance Type',
dataIndex: 'gpu_type',
key: 'gpu_type',
render: (_v: string, row: ResourceBreakdownItem) => (
<InstanceTypeCell
title={instanceTypeTitle(row)}
name={row.instance_name}
sections={instanceTypeSections(row, {
vram: intl.formatMessage({ id: 'gpuservice.instance.memory' }),
disk: intl.formatMessage({ id: 'gpuservice.instance.disk' })
})}
/>
)
};
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,
@@ -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 }) => (
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
{text}
<Tooltip title={tooltip} styles={{ root: { maxWidth: 320 } }}>
<QuestionCircleOutlined
className="m-l-5"
style={{ cursor: 'help', opacity: 0.6 }}
/>
</Tooltip>
</span>
);
export default MetricLabel;
+29 -19
View File
@@ -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<string, string> = {
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<string, string> = 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 (
<span style={{ color: 'var(--ant-color-error)' }}>
{row.phase_message || humanizePhase(row.phase)}
</span>
);
}
return v ?? '-';
}
}
],
[]
@@ -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<ResourceFilterBarProps> = (props) => {
const userOptionRender = (option: any) => (
<span className="flex-center gap-4">
<AutoTooltip ghost>{option?.data?.label}</AutoTooltip>
{option?.data?.deleted && (
<span
className="text-tertiary"
style={{ fontSize: 12, marginRight: 4 }}
>
[{intl.formatMessage({ id: 'usage.table.deleted' })}]
</span>
)}
</span>
);
+70 -4
View File
@@ -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<ResourceBreakdownRequest, 'group_by'> => ({
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<Metric, string> = {
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: (
<MetricLabel
text="GB-Days"
tooltip="Storage capacity integrated over time, in GB × days: 10 GB kept for 5 days = 50 GB-days. (= GB-Hours ÷ 24)"
/>
),
color: coolColors[0]
},
{
label: formatLargeNumber(
Math.round((summary?.storage_gb_hours ?? 0) * 10) / 10
) as string,
value: 'GB-Hours',
value: (
<MetricLabel
text="GB-Hours"
tooltip="Storage capacity integrated over time, in GB × hours: 10 GB kept for 5 hours = 50 GB-hours."
/>
),
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,
@@ -423,6 +423,10 @@ const SummaryTab: React.FC = () => {
headline={
<>
<Stat value={fmt(summary?.gpu_hours)} label="GPU Hours" />
<Stat
value={fmt(summary?.instance_hours)}
label="Instance Hours"
/>
<Stat
value={computeSum?.active_instances ?? 0}
label="Active Instances"