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:
@@ -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 ?? '-';
|
||||
}
|
||||
}
|
||||
],
|
||||
[]
|
||||
|
||||
Reference in New Issue
Block a user