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:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user