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.)
572 lines
18 KiB
TypeScript
572 lines
18 KiB
TypeScript
/**
|
||
* 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;
|