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
@@ -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,