diff --git a/src/pages/usage/apis/resource.ts b/src/pages/usage/apis/resource.ts index dc873cb4..7d541d84 100644 --- a/src/pages/usage/apis/resource.ts +++ b/src/pages/usage/apis/resource.ts @@ -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', '']; a table uses ['']. + 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 diff --git a/src/pages/usage/components/gpu-instances-tab.tsx b/src/pages/usage/components/gpu-instances-tab.tsx index daa1a0ff..6f5d60eb 100644 --- a/src/pages/usage/components/gpu-instances-tab.tsx +++ b/src/pages/usage/components/gpu-instances-tab.tsx @@ -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('gpu_hours'); const [granularity, setGranularity] = useState('day'); + // Optional trend group-by (split the chart into one series per group). + const [chartGroupBy, setChartGroupBy] = useState(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('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(); - 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)} /> diff --git a/src/pages/usage/components/metric-chart-card.tsx b/src/pages/usage/components/metric-chart-card.tsx index 809793d1..edc219d2 100644 --- a/src/pages/usage/components/metric-chart-card.tsx +++ b/src/pages/usage/components/metric-chart-card.tsx @@ -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 = ({ @@ -56,7 +61,10 @@ const MetricChartCard: React.FC = ({ onMetricChange, onGranularityChange, seriesData, - xAxisData + xAxisData, + groupBy, + groupByOptions, + onGroupByChange }) => { const intl = useIntl(); @@ -84,6 +92,22 @@ const MetricChartCard: React.FC = ({ onChange={onMetricChange} style={{ width: 'max-content' }} /> + {groupByOptions?.length && onGroupByChange ? ( + + {intl.formatMessage({ id: 'usage.filter.groupBy' })} + + } + options={groupByOptions} + value={groupBy ?? undefined} + popupMatchSelectWidth={false} + onChange={(v: string) => onGroupByChange(v || null)} + style={{ width: 'max-content', minWidth: 140 }} + /> + ) : null} = ({ 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 + } /> ); diff --git a/src/pages/usage/components/storage-tab.tsx b/src/pages/usage/components/storage-tab.tsx index 365d2147..5b72681d 100644 --- a/src/pages/usage/components/storage-tab.tsx +++ b/src/pages/usage/components/storage-tab.tsx @@ -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('storage_gb_days'); const [granularity, setGranularity] = useState('day'); + // Optional trend group-by (split the chart into one series per group). + const [chartGroupBy, setChartGroupBy] = useState(null); const [activeTableTab, setActiveTableTab] = useState('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(); - 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)} /> diff --git a/src/pages/usage/components/summary-tab.tsx b/src/pages/usage/components/summary-tab.tsx index 7fbe695e..55f67c21 100644 --- a/src/pages/usage/components/summary-tab.tsx +++ b/src/pages/usage/components/summary-tab.tsx @@ -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, diff --git a/src/pages/usage/utils/trend-series.ts b/src/pages/usage/utils/trend-series.ts new file mode 100644 index 00000000..b87e0bea --- /dev/null +++ b/src/pages/usage/utils/trend-series.ts @@ -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", ""]`` 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)[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(); + (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>(); + 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' + })); +};