feat(usage): group-by on the GPU Instances & Storage trend charts

Mirror the Tokens trend: add a clearable "Group by" select to the
MetricChartCard so the GPU Instances chart can split by instance type /
instance / user, and Storage by storage / user. When grouped, the chart
fetches group_by=["date", "<dim>"] (the same list style as the token usage
API) and pivots into one stacked series per group (shared buildTrendSeries
util), with a legend; ungrouped stays a single series. group_by is now a
list across the resource breakdown client; group-by options reuse the
bottom-table dimensions (Users only when org-wide).
This commit is contained in:
michelia
2026-06-09 11:54:28 +08:00
committed by michela feng
parent feccc81c5f
commit 68a7e3f66a
6 changed files with 236 additions and 68 deletions
+15 -13
View File
@@ -25,15 +25,9 @@ export interface ResourceBreakdownRequest {
end_date: string;
scope?: 'self' | 'all';
filters?: ResourceUsageFilters;
group_by?:
| 'date'
| 'resource_type'
| 'gpu_type'
| 'type'
| 'instance'
| 'user'
| 'volume'
| null;
// One or more grouping dimensions, combined left-to-right (mirrors the token
// usage API). A trend uses ['date', '<dim>']; a table uses ['<dim>'].
group_by?: string[];
granularity?: 'hour' | 'day' | 'week' | 'month';
// Server-side sort: a metric key (e.g. gpu_hours / instance_hours) +
// direction. Defaults on the server when omitted.
@@ -69,6 +63,9 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
volume_name?: string;
user_id?: number;
user_name?: string;
// Grouped-trend rows carry the sub-group label (sku / instance / user / …)
// alongside ``date`` so the chart can pivot one series per group.
group?: string;
last_active?: string;
// Instance-type rows carry the flavor's display fields (pretty product name +
// per-card specs) so the UI matches the GPU Instances list.
@@ -276,6 +273,9 @@ function flattenItem(
// Deleted entities get a "(Deleted)" suffix, matching the Token breakdown.
const rawKey = it.key ?? undefined;
const key = it.deleted && rawKey != null ? `${rawKey} (Deleted)` : rawKey;
// Generic group label — for a compound (date + dim) trend row the key is the
// sub-group value (the switch below targets single-dimension table rows).
if (rawKey != null) flat.group = key;
switch (groupBy) {
case 'resource_type':
flat.resource_type = key;
@@ -337,14 +337,16 @@ function flattenResponse(
}
function toServerRequest(data: ResourceBreakdownRequest) {
const groupBy = data.group_by ?? 'resource_type';
const groupByList = data.group_by?.length ? data.group_by : ['resource_type'];
const { creator_ids, instance_ids, volume_ids } = data.filters ?? {};
// The non-date dimension drives response flattening into the right field.
const dim = groupByList.find((g) => g !== 'date');
return {
body: {
start_date: data.start_date,
end_date: data.end_date,
scope: data.scope ?? 'all',
group_by: GROUP_BY_MAP[groupBy] ?? groupBy,
group_by: groupByList.map((g) => GROUP_BY_MAP[g] ?? g),
granularity: data.granularity ?? 'day',
// POST endpoints take proper id arrays. "filter by user" + "filter by
// resource" (instance ids on the GPU tab / volume ids on Storage).
@@ -356,7 +358,7 @@ function toServerRequest(data: ResourceBreakdownRequest) {
page: data.page ?? 1,
perPage: data.perPage ?? 20
},
groupBy
groupBy: dim
};
}
@@ -488,7 +490,7 @@ export async function queryUsageSummary(params: {
start_date: params.start_date,
end_date: params.end_date,
scope: params.scope ?? 'all',
group_by: 'gpu_type',
group_by: ['gpu_type'],
...(creator_ids?.length ? { filters: { creator_ids } } : {}),
page: 1,
perPage: 100
@@ -34,6 +34,7 @@ import {
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';
@@ -75,10 +76,11 @@ const GpuInstancesTab: React.FC = () => {
],
[intl]
);
// ``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.
const coolColors = useCoolColors()(5);
// ``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.
@@ -94,6 +96,8 @@ const GpuInstancesTab: React.FC = () => {
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');
@@ -139,7 +143,10 @@ const GpuInstancesTab: React.FC = () => {
try {
const data = await queryGpuInstancesBreakdown({
...baseRequest(),
group_by: 'date'
// 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 {
@@ -152,7 +159,7 @@ const GpuInstancesTab: React.FC = () => {
try {
const data = await queryGpuInstancesBreakdown({
...baseRequest(),
group_by: activeTableTab,
group_by: [activeTableTab],
page: tablePage,
order_by: tableSort.field,
descending: tableSort.order === 'descend'
@@ -165,7 +172,14 @@ const GpuInstancesTab: React.FC = () => {
useEffect(() => {
fetchChart();
}, [dateRange, selectedUsers, selectedInstances, granularity, refreshKey]);
}, [
dateRange,
selectedUsers,
selectedInstances,
granularity,
chartGroupBy,
refreshKey
]);
useEffect(() => {
fetchTable();
@@ -224,16 +238,7 @@ const GpuInstancesTab: React.FC = () => {
[summary, coolColors, intl]
);
// Build chart series — single series of the selected metric, plotted
// along the contiguous date range.
const dataByDate = useMemo(() => {
const map = new Map<string, number>();
chartData?.items?.forEach((item) => {
if (!item.date) return;
map.set(bucketKey(item.date, granularity), Number(item[metric] ?? 0));
});
return map;
}, [chartData, metric, granularity]);
// x-axis = the contiguous date range plus any buckets present in the data.
const xAxis = useMemo(() => {
const keys = new Set(
generateBucketRange(
@@ -242,17 +247,40 @@ const GpuInstancesTab: React.FC = () => {
granularity
)
);
dataByDate.forEach((_v, k) => keys.add(k));
chartData?.items?.forEach((i) => {
if (i.date) keys.add(bucketKey(i.date, granularity));
});
return Array.from(keys).sort();
}, [dataByDate, dateRange, granularity]);
}, [chartData, dateRange, granularity]);
const seriesData = [
{
name: METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric,
data: xAxis.map((d) => dataByDate.get(d) ?? 0),
color: coolColors[0]
}
];
// 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(() => {
@@ -392,13 +420,13 @@ const GpuInstancesTab: React.FC = () => {
const exportConfig =
exportMode === 'chart'
? {
groupBy: 'date' as const,
groupBy: ['date'],
columns: chartExportColumns,
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
}
: {
groupBy: activeTableTab,
groupBy: [activeTableTab],
columns: tableColumns,
fileName: `gpu-instances_${activeTableTab}_${dateSuffix}.xlsx`,
sheetName: tabLabel || 'gpu-instances'
@@ -459,6 +487,9 @@ const GpuInstancesTab: React.FC = () => {
onGranularityChange={(v) => setGranularity(v as Granularity)}
seriesData={seriesData}
xAxisData={xAxis}
groupBy={chartGroupBy}
groupByOptions={chartGroupByOptions}
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
/>
</div>
@@ -47,6 +47,11 @@ interface MetricChartCardProps {
onGranularityChange: (value: string) => void;
seriesData: BarSeriesItem[];
xAxisData: string[];
// Optional group-by control (mirrors the Tokens trend). When provided, a
// clearable "Group by" select is shown; clearing passes null.
groupBy?: string | null;
groupByOptions?: MetricOption[];
onGroupByChange?: (value: string | null) => void;
}
const MetricChartCard: React.FC<MetricChartCardProps> = ({
@@ -56,7 +61,10 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
onMetricChange,
onGranularityChange,
seriesData,
xAxisData
xAxisData,
groupBy,
groupByOptions,
onGroupByChange
}) => {
const intl = useIntl();
@@ -84,6 +92,22 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
onChange={onMetricChange}
style={{ width: 'max-content' }}
/>
{groupByOptions?.length && onGroupByChange ? (
<BaseSelect
allowClear
variant="borderless"
prefix={
<ControlLabel>
{intl.formatMessage({ id: 'usage.filter.groupBy' })}
</ControlLabel>
}
options={groupByOptions}
value={groupBy ?? undefined}
popupMatchSelectWidth={false}
onChange={(v: string) => onGroupByChange(v || null)}
style={{ width: 'max-content', minWidth: 140 }}
/>
) : null}
</div>
<Segmented
size="small"
@@ -109,6 +133,12 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
xAxisData={xAxisData}
height={280}
labelFormatter={labelFormatter}
// Auto-show a legend once the trend is split into multiple series.
legendData={
seriesData.length > 1
? seriesData.map((s) => ({ name: s.name }))
: undefined
}
/>
</CardWrapper>
);
+62 -23
View File
@@ -30,6 +30,7 @@ import {
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';
@@ -42,7 +43,10 @@ type GroupKey = 'volume' | 'user';
const StorageTab: React.FC = () => {
const access = useAccess();
const intl = useIntl();
const coolColors = useCoolColors()(4);
// Factory kept for the grouped trend (sized to group count); 4-slot palette
// for the KPI cards.
const colorFactory = useCoolColors();
const coolColors = colorFactory(4);
const METRIC_OPTIONS: { value: Metric; label: string }[] = useMemo(
() => [
@@ -83,6 +87,8 @@ const StorageTab: React.FC = () => {
const [refreshKey, setRefreshKey] = useState(0);
const [metric, setMetric] = useState<Metric>('storage_gb_days');
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);
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('volume');
const { creators: userOptions, volumes: volumeOptions } =
@@ -121,7 +127,10 @@ const StorageTab: React.FC = () => {
try {
const data = await queryStorageBreakdown({
...baseRequest(),
group_by: 'date'
// 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 {
@@ -140,7 +149,7 @@ const StorageTab: React.FC = () => {
try {
const data = await queryStorageBreakdown({
...baseRequest(),
group_by: activeTableTab,
group_by: [activeTableTab],
page: tablePage,
order_by: ORDER_BY_KEY[tableSort.field],
descending: tableSort.order === 'descend'
@@ -153,7 +162,14 @@ const StorageTab: React.FC = () => {
useEffect(() => {
fetchChart();
}, [dateRange, selectedUsers, selectedVolumes, granularity, refreshKey]);
}, [
dateRange,
selectedUsers,
selectedVolumes,
granularity,
chartGroupBy,
refreshKey
]);
useEffect(() => {
fetchTable();
@@ -208,14 +224,6 @@ const StorageTab: React.FC = () => {
[summary, coolColors, intl]
);
const dataByDate = useMemo(() => {
const map = new Map<string, number>();
chartData?.items?.forEach((item) => {
if (!item.date) return;
map.set(bucketKey(item.date, granularity), Number(item[metric] ?? 0));
});
return map;
}, [chartData, metric, granularity]);
const xAxis = useMemo(() => {
const keys = new Set(
generateBucketRange(
@@ -224,17 +232,45 @@ const StorageTab: React.FC = () => {
granularity
)
);
dataByDate.forEach((_v, k) => keys.add(k));
chartData?.items?.forEach((i) => {
if (i.date) keys.add(bucketKey(i.date, granularity));
});
return Array.from(keys).sort();
}, [dataByDate, dateRange, granularity]);
}, [chartData, dateRange, granularity]);
const seriesData = [
{
name: METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric,
data: xAxis.map((d) => dataByDate.get(d) ?? 0),
color: coolColors[0]
}
];
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,
METRIC_OPTIONS
]
);
const chartGroupByOptions = useMemo(
() =>
TABLE_TABS.filter((t) => t.key !== 'user' || scope === 'all').map(
(t) => ({
value: t.key,
label: t.label
})
),
[TABLE_TABS, scope]
);
const tableColumns = useMemo(() => {
const valueCols = [
@@ -349,13 +385,13 @@ const StorageTab: React.FC = () => {
const exportConfig =
exportMode === 'chart'
? {
groupBy: 'date' as const,
groupBy: ['date'],
columns: chartExportColumns,
fileName: `storage_chart_${dateSuffix}.xlsx`,
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
}
: {
groupBy: activeTableTab,
groupBy: [activeTableTab],
columns: tableColumns,
fileName: `storage_${activeTableTab}_${dateSuffix}.xlsx`,
sheetName: tabLabel || 'storage'
@@ -413,6 +449,9 @@ const StorageTab: React.FC = () => {
onGranularityChange={(v) => setGranularity(v as Granularity)}
seriesData={seriesData}
xAxisData={xAxis}
groupBy={chartGroupBy}
groupByOptions={chartGroupByOptions}
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
/>
</div>
+3 -3
View File
@@ -258,7 +258,7 @@ const SummaryTab: React.FC = () => {
start_date: start,
end_date: end,
scope,
group_by: 'type',
group_by: ['type'],
filters: creatorFilter,
page: 1,
perPage: 100
@@ -284,7 +284,7 @@ const SummaryTab: React.FC = () => {
start_date: start,
end_date: end,
scope,
group_by: 'date',
group_by: ['date'],
granularity,
filters: creatorFilter,
page: 1,
@@ -296,7 +296,7 @@ const SummaryTab: React.FC = () => {
start_date: start,
end_date: end,
scope,
group_by: 'date',
group_by: ['date'],
granularity,
filters: creatorFilter,
page: 1,
+66
View File
@@ -0,0 +1,66 @@
/**
* Build bar-chart series for the resource trend charts (GPU Instances /
* Storage), shared so both tabs behave identically.
*
* Without a group dimension it's a single series of the chosen metric. With
* one (the backend returns ``group_by=["date", "<dim>"]`` rows, each carrying
* ``date`` + ``group``), it pivots into one stacked series per group value
* over the date axis — mirroring the Tokens tab's grouped trend.
*/
import type { BarSeriesItem } from '@/pages/_components/bar-chart';
import { ResourceBreakdownItem } from '../apis/resource';
import { bucketKey, Granularity } from './time-buckets';
const valueOf = (item: ResourceBreakdownItem, metric: string): number =>
Number((item as Record<string, any>)[metric] ?? 0);
export const buildTrendSeries = (opts: {
items?: ResourceBreakdownItem[];
metric: string;
granularity: Granularity;
xAxis: string[];
groupBy: string | null;
// Palette factory (e.g. useCoolColors()); called with the series count.
palette: (n: number) => string[];
singleName: string;
}): BarSeriesItem[] => {
const { items, metric, granularity, xAxis, groupBy, palette, singleName } =
opts;
if (!groupBy) {
const byDate = new Map<string, number>();
(items || []).forEach((i) => {
if (!i.date) return;
byDate.set(bucketKey(i.date, granularity), valueOf(i, metric));
});
return [
{
name: singleName,
data: xAxis.map((d) => byDate.get(d) ?? 0),
color: palette(1)[0]
}
];
}
// Group label → (date bucket → value). ``order`` preserves first-seen order
// so colors stay stable as the date range scrolls.
const byGroup = new Map<string, Map<string, number>>();
const order: string[] = [];
(items || []).forEach((i) => {
if (!i.date) return;
const label = i.group || 'unknown';
if (!byGroup.has(label)) {
byGroup.set(label, new Map());
order.push(label);
}
byGroup.get(label)!.set(bucketKey(i.date, granularity), valueOf(i, metric));
});
const colors = palette(Math.max(order.length, 1));
return order.map((label, idx) => ({
name: label,
data: xAxis.map((d) => byGroup.get(label)?.get(d) ?? 0),
color: colors[idx % colors.length],
stack: 'total'
}));
};