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
+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 ?? '-';
}
}
],
[]