Files
gpustack-ui/src/pages/usage/components/gpu-instances-tab.tsx
T
micheliaandmichela feng 7ce1faaf6c fix(usage): format Last Active as local time on GPU & Storage tabs
The Last Active column had no renderer, so it dumped the raw ISO timestamp
(gpustack/gpustack#5523). It's a UTC instant (max bucket hour), so format it
to the user's local time with dayjs — matching the Resource Events time
column and the rest of the product. (Token Last Active is a daily date, not
an instant, so it stays a plain date with no timezone shift.)
2026-06-09 11:54:28 +08:00

572 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GPU Instances Tab — per-instance compute usage view.
*
* Layout mirrors the existing Token tab:
* 1. Top filter bar (date range + scope)
* 2. KPI row (GPU-Hours / GPU-Minutes / Instances / GPU Types / Active Users)
* 3. Daily bar chart with metric + group_by switches
* 4. Bottom tab table grouped by GPU Type / Instance / User
*
* 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, useIntl } from '@umijs/max';
import { Table, Tabs } from 'antd';
import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react';
import {
queryGpuInstancesBreakdown,
ResourceBreakdownItem,
ResourceBreakdownRequest,
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 { buildTrendSeries } from '../utils/trend-series';
import MetricChartCard from './metric-chart-card';
import MetricLabel from './metric-label';
import ResourceExportData from './resource-export-data';
import ResourceFilterBar from './resource-filter-bar';
type Scope = 'self' | 'all';
type Metric = 'gpu_hours' | 'instance_hours';
type GroupKey = 'gpu_type' | 'instance' | 'user';
const GpuInstancesTab: React.FC = () => {
const access = useAccess();
const intl = useIntl();
const METRIC_OPTIONS: { value: Metric; label: string }[] = useMemo(
() => [
{
value: 'gpu_hours',
label: intl.formatMessage({ id: 'usage.metric.gpuHours' })
},
{
value: 'instance_hours',
label: intl.formatMessage({ id: 'usage.metric.instanceHours' })
}
],
[intl]
);
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(
() => [
{
key: 'gpu_type',
label: intl.formatMessage({ id: 'usage.table.instanceTypes' })
},
{
key: 'instance',
label: intl.formatMessage({ id: 'usage.table.instances' })
},
{ key: 'user', label: intl.formatMessage({ id: 'usage.table.users' }) }
],
[intl]
);
// ``useCoolColors`` returns a memoized factory; resolve a fixed 5-slot
// palette for the KPI cards, and keep the factory for the grouped trend
// (sized to the group count).
const colorFactory = useCoolColors();
const coolColors = colorFactory(5);
// No All/My dropdown (matches the Tokens tab): managers see the org-wide
// view and narrow it with the user filter, others only their own rows.
const canManageUsers = !!access.canSeeOrgAdmin;
const scope: Scope = canManageUsers ? 'all' : 'self';
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([
dayjs().subtract(29, 'day'),
dayjs()
]);
const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
const [selectedInstances, setSelectedInstances] = useState<number[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const [metric, setMetric] = useState<Metric>('gpu_hours');
const [granularity, setGranularity] = useState<Granularity>('day');
// Optional trend group-by (split the chart into one series per group).
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
// ``null`` group_by = no row grouping, just the summary KPIs.
// The chart needs the ``date`` group; tables use the active table tab.
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('gpu_type');
const { creators: userOptions, instances: instanceOptions } =
useResourceMeta(scope);
// Two independent fetches: one for the daily chart (group_by=date),
// one for the table (group_by=tab key). Both reuse the same date /
// scope filters so the views stay in sync.
const [chartData, setChartData] = useState<ResourceBreakdownResponse | null>(
null
);
const [tableData, setTableData] = useState<ResourceBreakdownResponse | null>(
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'),
end_date: dateRange[1].format('YYYY-MM-DD'),
scope,
granularity,
filters:
selectedUsers.length || selectedInstances.length
? {
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
...(selectedInstances.length
? { instance_ids: selectedInstances }
: {})
}
: undefined,
page: 1,
perPage: 50
});
const fetchChart = async () => {
try {
const data = await queryGpuInstancesBreakdown({
...baseRequest(),
// Split each bucket by the chosen dimension when grouping; fetch the
// whole range (date × groups can exceed a normal page).
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
...(chartGroupBy ? { perPage: 10000 } : {})
});
setChartData(data);
} catch {
// Network/auth errors surface via the global request interceptor;
// keep the previous chart so the UI doesn't flash empty.
}
};
const fetchTable = async () => {
try {
const data = await queryGpuInstancesBreakdown({
...baseRequest(),
group_by: [activeTableTab],
page: tablePage,
order_by: tableSort.field,
descending: tableSort.order === 'descend'
});
setTableData(data);
} catch {
// Same rationale as fetchChart.
}
};
useEffect(() => {
fetchChart();
}, [
dateRange,
selectedUsers,
selectedInstances,
granularity,
chartGroupBy,
refreshKey
]);
useEffect(() => {
fetchTable();
}, [
dateRange,
selectedUsers,
selectedInstances,
activeTableTab,
tablePage,
tableSort,
refreshKey
]);
// KPI summary cards — pull from the chart summary since both queries
// return the same scope-wide totals.
const summary = chartData?.summary;
const summaryCards = useMemo(
() => [
{
label: formatLargeNumber(
Math.round((summary?.gpu_hours ?? 0) * 10) / 10
) as string,
value: (
<MetricLabel
text={intl.formatMessage({ id: 'usage.metric.gpuHours' })}
tooltip={intl.formatMessage({ id: 'usage.metric.gpuHours.tip' })}
/>
),
color: coolColors[0]
},
{
label: formatLargeNumber(
Math.round((summary?.instance_hours ?? 0) * 10) / 10
) as string,
value: (
<MetricLabel
text={intl.formatMessage({ id: 'usage.metric.instanceHours' })}
tooltip={intl.formatMessage({
id: 'usage.metric.instanceHours.tip'
})}
/>
),
color: coolColors[1]
},
{
label: (summary?.active_instances ?? 0).toString(),
value: intl.formatMessage({ id: 'usage.metric.activeInstances' }),
color: coolColors[2]
},
{
label: (summary?.active_users ?? 0).toString(),
value: intl.formatMessage({ id: 'usage.metric.activeUsers' }),
color: coolColors[3]
}
],
[summary, coolColors, intl]
);
// x-axis = the contiguous date range plus any buckets present in the data.
const xAxis = useMemo(() => {
const keys = new Set(
generateBucketRange(
dateRange[0].format('YYYY-MM-DD'),
dateRange[1].format('YYYY-MM-DD'),
granularity
)
);
chartData?.items?.forEach((i) => {
if (i.date) keys.add(bucketKey(i.date, granularity));
});
return Array.from(keys).sort();
}, [chartData, dateRange, granularity]);
// Single series, or one stacked series per group when grouping is on.
const seriesData = useMemo(
() =>
buildTrendSeries({
items: chartData?.items,
metric,
granularity,
xAxis,
groupBy: chartGroupBy,
palette: colorFactory,
singleName:
METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric
}),
[chartData, metric, granularity, xAxis, chartGroupBy, colorFactory]
);
// Group-by options for the trend = the same dimensions as the bottom tables
// (Users only when org-wide, matching the table tabs).
const chartGroupByOptions = useMemo(
() =>
TABLE_TABS.filter((t) => t.key !== 'user' || scope === 'all').map(
(t) => ({
value: t.key,
label: t.label
})
),
[TABLE_TABS, scope]
);
// Table columns adapt to the active tab.
const tableColumns = useMemo(() => {
const baseValueCols = [
{
title: intl.formatMessage({ id: 'usage.metric.gpuHours' }),
dataIndex: 'gpu_hours',
key: 'gpu_hours',
sorter: true,
sortOrder: tableSort.field === 'gpu_hours' ? tableSort.order : null,
render: (v: number) => (v ?? 0).toFixed(2)
},
{
title: intl.formatMessage({ id: 'usage.metric.instanceHours' }),
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: intl.formatMessage({ id: 'usage.table.instanceType' }),
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: intl.formatMessage({ id: 'usage.table.instanceType' }),
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' })
})}
/>
)
};
// Last Active is a UTC instant (max bucket hour) → show formatted local time.
const lastActiveCol = {
title: intl.formatMessage({ id: 'usage.table.lastActive' }),
dataIndex: 'last_active',
key: 'last_active',
render: (v?: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-')
};
if (activeTableTab === 'gpu_type') {
return [
instanceTypeColType,
...baseValueCols,
{
title: intl.formatMessage({ id: 'usage.metric.activeInstances' }),
dataIndex: 'active_instances',
key: 'active_instances'
},
lastActiveCol
];
}
if (activeTableTab === 'instance') {
return [
{
title: intl.formatMessage({ id: 'usage.table.instance' }),
dataIndex: 'instance_name',
key: 'instance_name'
},
instanceTypeColInstance,
...baseValueCols,
lastActiveCol
];
}
// user tab
return [
{
title: intl.formatMessage({ id: 'usage.table.user' }),
dataIndex: 'user_name',
key: 'user_name'
},
...baseValueCols,
lastActiveCol
];
}, [activeTableTab, tableSort, intl]);
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? [];
// Export opens a preview modal (matches the Tokens tab): re-filter + preview
// the rows, then download. "Chart" = the by-date trend, "Table" = the active
// bottom-table grouping.
const [exportMode, setExportMode] = useState<'chart' | 'table' | null>(null);
const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format(
'YYYY-MM-DD'
)}`;
const chartExportColumns = [
{
title: intl.formatMessage({ id: 'usage.table.date' }),
dataIndex: 'date',
key: 'date'
},
{
title: intl.formatMessage({ id: 'usage.metric.gpuHours' }),
dataIndex: 'gpu_hours',
key: 'gpu_hours',
render: (v: number) => (v ?? 0).toFixed(2)
},
{
title: intl.formatMessage({ id: 'usage.metric.instanceHours' }),
dataIndex: 'instance_hours',
key: 'instance_hours',
render: (v: number) => (v ?? 0).toFixed(2)
},
{
title: intl.formatMessage({ id: 'usage.metric.activeInstances' }),
dataIndex: 'active_instances',
key: 'active_instances'
},
{
title: intl.formatMessage({ id: 'usage.metric.activeUsers' }),
dataIndex: 'active_users',
key: 'active_users'
}
];
const tabLabel = TABLE_TABS.find((t) => t.key === activeTableTab)?.label;
const exportConfig =
exportMode === 'chart'
? {
groupBy: ['date'],
columns: chartExportColumns,
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
}
: {
groupBy: [activeTableTab],
columns: tableColumns,
fileName: `gpu-instances_${activeTableTab}_${dateSuffix}.xlsx`,
sheetName: tabLabel || 'gpu-instances'
};
return (
<div>
{/* Top filter row */}
<ResourceFilterBar
value={dateRange}
onChange={(dates) => {
setDateRange(dates);
setTablePage(1);
}}
canManageUsers={canManageUsers}
userOptions={userOptions}
selectedUsers={selectedUsers}
onUsersChange={(ids) => {
setSelectedUsers(ids);
setTablePage(1);
}}
resourceFilter={{
options: instanceOptions,
value: selectedInstances,
onChange: (ids) => {
setSelectedInstances(ids);
setTablePage(1);
},
placeholder: intl.formatMessage({ id: 'usage.filter.instance' })
}}
onRefresh={() => setRefreshKey((k) => k + 1)}
onExportChart={() => setExportMode('chart')}
onExportTable={() => setExportMode('table')}
/>
{/* KPI cards */}
<div style={{ height: 24 }} />
<div style={{ marginBottom: 24 }}>
<SimpleCard
dataList={summaryCards}
height={80}
styles={{
item: {
backgroundColor: 'var(--ant-color-fill-quaternary)',
borderRadius: 6
}
}}
/>
</div>
{/* Daily trend chart */}
<div style={{ marginBottom: 24 }}>
<MetricChartCard
metric={metric}
metricOptions={METRIC_OPTIONS}
granularity={granularity}
onMetricChange={(v) => setMetric(v as Metric)}
onGranularityChange={(v) => setGranularity(v as Granularity)}
seriesData={seriesData}
xAxisData={xAxis}
groupBy={chartGroupBy}
groupByOptions={chartGroupByOptions}
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
/>
</div>
{/* Bottom tabs + table */}
<Tabs
activeKey={activeTableTab}
onChange={(k) => {
setActiveTableTab(k as GroupKey);
setTablePage(1);
}}
items={TABLE_TABS.filter(
(t) => t.key !== 'user' || scope === 'all'
).map((t) => ({
key: t.key,
label: t.label,
children: (
<Table
rowKey={(row) =>
`${row.gpu_type ?? ''}|${row.instance_id ?? ''}|${row.user_id ?? ''}`
}
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,
total: tableData?.pagination.total ?? 0,
onChange: (p) => setTablePage(p)
}}
/>
)
}))}
/>
<ResourceExportData
open={exportMode !== null}
onCancel={() => setExportMode(null)}
title={
exportMode === 'chart'
? intl.formatMessage({ id: 'usage.export.chart' })
: intl.formatMessage(
{ id: 'usage.export.tableNamed' },
{ name: tabLabel }
)
}
queryFn={queryGpuInstancesBreakdown}
groupBy={exportConfig.groupBy}
columns={exportConfig.columns}
fileName={exportConfig.fileName}
sheetName={exportConfig.sheetName}
scope={scope}
canManageUsers={canManageUsers}
userOptions={userOptions}
resourceFilter={{
options: instanceOptions,
placeholder: intl.formatMessage({ id: 'usage.filter.instance' }),
key: 'instance_ids'
}}
initialDateRange={dateRange}
initialSelectedUsers={selectedUsers}
initialSelectedResources={selectedInstances}
/>
</div>
);
};
export default GpuInstancesTab;