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:
@@ -25,15 +25,9 @@ export interface ResourceBreakdownRequest {
|
|||||||
end_date: string;
|
end_date: string;
|
||||||
scope?: 'self' | 'all';
|
scope?: 'self' | 'all';
|
||||||
filters?: ResourceUsageFilters;
|
filters?: ResourceUsageFilters;
|
||||||
group_by?:
|
// One or more grouping dimensions, combined left-to-right (mirrors the token
|
||||||
| 'date'
|
// usage API). A trend uses ['date', '<dim>']; a table uses ['<dim>'].
|
||||||
| 'resource_type'
|
group_by?: string[];
|
||||||
| 'gpu_type'
|
|
||||||
| 'type'
|
|
||||||
| 'instance'
|
|
||||||
| 'user'
|
|
||||||
| 'volume'
|
|
||||||
| null;
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month';
|
granularity?: 'hour' | 'day' | 'week' | 'month';
|
||||||
// Server-side sort: a metric key (e.g. gpu_hours / instance_hours) +
|
// Server-side sort: a metric key (e.g. gpu_hours / instance_hours) +
|
||||||
// direction. Defaults on the server when omitted.
|
// direction. Defaults on the server when omitted.
|
||||||
@@ -69,6 +63,9 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
|
|||||||
volume_name?: string;
|
volume_name?: string;
|
||||||
user_id?: number;
|
user_id?: number;
|
||||||
user_name?: string;
|
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;
|
last_active?: string;
|
||||||
// Instance-type rows carry the flavor's display fields (pretty product name +
|
// Instance-type rows carry the flavor's display fields (pretty product name +
|
||||||
// per-card specs) so the UI matches the GPU Instances list.
|
// 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.
|
// Deleted entities get a "(Deleted)" suffix, matching the Token breakdown.
|
||||||
const rawKey = it.key ?? undefined;
|
const rawKey = it.key ?? undefined;
|
||||||
const key = it.deleted && rawKey != null ? `${rawKey} (Deleted)` : rawKey;
|
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) {
|
switch (groupBy) {
|
||||||
case 'resource_type':
|
case 'resource_type':
|
||||||
flat.resource_type = key;
|
flat.resource_type = key;
|
||||||
@@ -337,14 +337,16 @@ function flattenResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toServerRequest(data: ResourceBreakdownRequest) {
|
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 ?? {};
|
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 {
|
return {
|
||||||
body: {
|
body: {
|
||||||
start_date: data.start_date,
|
start_date: data.start_date,
|
||||||
end_date: data.end_date,
|
end_date: data.end_date,
|
||||||
scope: data.scope ?? 'all',
|
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',
|
granularity: data.granularity ?? 'day',
|
||||||
// POST endpoints take proper id arrays. "filter by user" + "filter by
|
// POST endpoints take proper id arrays. "filter by user" + "filter by
|
||||||
// resource" (instance ids on the GPU tab / volume ids on Storage).
|
// resource" (instance ids on the GPU tab / volume ids on Storage).
|
||||||
@@ -356,7 +358,7 @@ function toServerRequest(data: ResourceBreakdownRequest) {
|
|||||||
page: data.page ?? 1,
|
page: data.page ?? 1,
|
||||||
perPage: data.perPage ?? 20
|
perPage: data.perPage ?? 20
|
||||||
},
|
},
|
||||||
groupBy
|
groupBy: dim
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,7 +490,7 @@ export async function queryUsageSummary(params: {
|
|||||||
start_date: params.start_date,
|
start_date: params.start_date,
|
||||||
end_date: params.end_date,
|
end_date: params.end_date,
|
||||||
scope: params.scope ?? 'all',
|
scope: params.scope ?? 'all',
|
||||||
group_by: 'gpu_type',
|
group_by: ['gpu_type'],
|
||||||
...(creator_ids?.length ? { filters: { creator_ids } } : {}),
|
...(creator_ids?.length ? { filters: { creator_ids } } : {}),
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 100
|
perPage: 100
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
generateBucketRange,
|
generateBucketRange,
|
||||||
Granularity
|
Granularity
|
||||||
} from '../utils/time-buckets';
|
} from '../utils/time-buckets';
|
||||||
|
import { buildTrendSeries } from '../utils/trend-series';
|
||||||
import MetricChartCard from './metric-chart-card';
|
import MetricChartCard from './metric-chart-card';
|
||||||
import MetricLabel from './metric-label';
|
import MetricLabel from './metric-label';
|
||||||
import ResourceExportData from './resource-export-data';
|
import ResourceExportData from './resource-export-data';
|
||||||
@@ -75,10 +76,11 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
],
|
],
|
||||||
[intl]
|
[intl]
|
||||||
);
|
);
|
||||||
// ``useCoolColors`` returns a memoized factory; resolve it once into a
|
// ``useCoolColors`` returns a memoized factory; resolve a fixed 5-slot
|
||||||
// fixed 5-slot palette here so the rest of the component reads as
|
// palette for the KPI cards, and keep the factory for the grouped trend
|
||||||
// array access.
|
// (sized to the group count).
|
||||||
const coolColors = useCoolColors()(5);
|
const colorFactory = useCoolColors();
|
||||||
|
const coolColors = colorFactory(5);
|
||||||
|
|
||||||
// No All/My dropdown (matches the Tokens tab): managers see the org-wide
|
// 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.
|
// 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 [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [metric, setMetric] = useState<Metric>('gpu_hours');
|
const [metric, setMetric] = useState<Metric>('gpu_hours');
|
||||||
const [granularity, setGranularity] = useState<Granularity>('day');
|
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.
|
// ``null`` group_by = no row grouping, just the summary KPIs.
|
||||||
// The chart needs the ``date`` group; tables use the active table tab.
|
// The chart needs the ``date`` group; tables use the active table tab.
|
||||||
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('gpu_type');
|
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('gpu_type');
|
||||||
@@ -139,7 +143,10 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await queryGpuInstancesBreakdown({
|
const data = await queryGpuInstancesBreakdown({
|
||||||
...baseRequest(),
|
...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);
|
setChartData(data);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -152,7 +159,7 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await queryGpuInstancesBreakdown({
|
const data = await queryGpuInstancesBreakdown({
|
||||||
...baseRequest(),
|
...baseRequest(),
|
||||||
group_by: activeTableTab,
|
group_by: [activeTableTab],
|
||||||
page: tablePage,
|
page: tablePage,
|
||||||
order_by: tableSort.field,
|
order_by: tableSort.field,
|
||||||
descending: tableSort.order === 'descend'
|
descending: tableSort.order === 'descend'
|
||||||
@@ -165,7 +172,14 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchChart();
|
fetchChart();
|
||||||
}, [dateRange, selectedUsers, selectedInstances, granularity, refreshKey]);
|
}, [
|
||||||
|
dateRange,
|
||||||
|
selectedUsers,
|
||||||
|
selectedInstances,
|
||||||
|
granularity,
|
||||||
|
chartGroupBy,
|
||||||
|
refreshKey
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTable();
|
fetchTable();
|
||||||
@@ -224,16 +238,7 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
[summary, coolColors, intl]
|
[summary, coolColors, intl]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build chart series — single series of the selected metric, plotted
|
// x-axis = the contiguous date range plus any buckets present in the data.
|
||||||
// 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]);
|
|
||||||
const xAxis = useMemo(() => {
|
const xAxis = useMemo(() => {
|
||||||
const keys = new Set(
|
const keys = new Set(
|
||||||
generateBucketRange(
|
generateBucketRange(
|
||||||
@@ -242,17 +247,40 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
granularity
|
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();
|
return Array.from(keys).sort();
|
||||||
}, [dataByDate, dateRange, granularity]);
|
}, [chartData, dateRange, granularity]);
|
||||||
|
|
||||||
const seriesData = [
|
// Single series, or one stacked series per group when grouping is on.
|
||||||
{
|
const seriesData = useMemo(
|
||||||
name: METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric,
|
() =>
|
||||||
data: xAxis.map((d) => dataByDate.get(d) ?? 0),
|
buildTrendSeries({
|
||||||
color: coolColors[0]
|
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.
|
// Table columns adapt to the active tab.
|
||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
@@ -392,13 +420,13 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
const exportConfig =
|
const exportConfig =
|
||||||
exportMode === 'chart'
|
exportMode === 'chart'
|
||||||
? {
|
? {
|
||||||
groupBy: 'date' as const,
|
groupBy: ['date'],
|
||||||
columns: chartExportColumns,
|
columns: chartExportColumns,
|
||||||
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
|
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
|
||||||
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
|
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
groupBy: activeTableTab,
|
groupBy: [activeTableTab],
|
||||||
columns: tableColumns,
|
columns: tableColumns,
|
||||||
fileName: `gpu-instances_${activeTableTab}_${dateSuffix}.xlsx`,
|
fileName: `gpu-instances_${activeTableTab}_${dateSuffix}.xlsx`,
|
||||||
sheetName: tabLabel || 'gpu-instances'
|
sheetName: tabLabel || 'gpu-instances'
|
||||||
@@ -459,6 +487,9 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
onGranularityChange={(v) => setGranularity(v as Granularity)}
|
onGranularityChange={(v) => setGranularity(v as Granularity)}
|
||||||
seriesData={seriesData}
|
seriesData={seriesData}
|
||||||
xAxisData={xAxis}
|
xAxisData={xAxis}
|
||||||
|
groupBy={chartGroupBy}
|
||||||
|
groupByOptions={chartGroupByOptions}
|
||||||
|
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ interface MetricChartCardProps {
|
|||||||
onGranularityChange: (value: string) => void;
|
onGranularityChange: (value: string) => void;
|
||||||
seriesData: BarSeriesItem[];
|
seriesData: BarSeriesItem[];
|
||||||
xAxisData: string[];
|
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> = ({
|
const MetricChartCard: React.FC<MetricChartCardProps> = ({
|
||||||
@@ -56,7 +61,10 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
|
|||||||
onMetricChange,
|
onMetricChange,
|
||||||
onGranularityChange,
|
onGranularityChange,
|
||||||
seriesData,
|
seriesData,
|
||||||
xAxisData
|
xAxisData,
|
||||||
|
groupBy,
|
||||||
|
groupByOptions,
|
||||||
|
onGroupByChange
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
@@ -84,6 +92,22 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
|
|||||||
onChange={onMetricChange}
|
onChange={onMetricChange}
|
||||||
style={{ width: 'max-content' }}
|
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>
|
</div>
|
||||||
<Segmented
|
<Segmented
|
||||||
size="small"
|
size="small"
|
||||||
@@ -109,6 +133,12 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
|
|||||||
xAxisData={xAxisData}
|
xAxisData={xAxisData}
|
||||||
height={280}
|
height={280}
|
||||||
labelFormatter={labelFormatter}
|
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>
|
</CardWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
generateBucketRange,
|
generateBucketRange,
|
||||||
Granularity
|
Granularity
|
||||||
} from '../utils/time-buckets';
|
} from '../utils/time-buckets';
|
||||||
|
import { buildTrendSeries } from '../utils/trend-series';
|
||||||
import MetricChartCard from './metric-chart-card';
|
import MetricChartCard from './metric-chart-card';
|
||||||
import MetricLabel from './metric-label';
|
import MetricLabel from './metric-label';
|
||||||
import ResourceExportData from './resource-export-data';
|
import ResourceExportData from './resource-export-data';
|
||||||
@@ -42,7 +43,10 @@ type GroupKey = 'volume' | 'user';
|
|||||||
const StorageTab: React.FC = () => {
|
const StorageTab: React.FC = () => {
|
||||||
const access = useAccess();
|
const access = useAccess();
|
||||||
const intl = useIntl();
|
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(
|
const METRIC_OPTIONS: { value: Metric; label: string }[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -83,6 +87,8 @@ const StorageTab: React.FC = () => {
|
|||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [metric, setMetric] = useState<Metric>('storage_gb_days');
|
const [metric, setMetric] = useState<Metric>('storage_gb_days');
|
||||||
const [granularity, setGranularity] = useState<Granularity>('day');
|
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 [activeTableTab, setActiveTableTab] = useState<GroupKey>('volume');
|
||||||
|
|
||||||
const { creators: userOptions, volumes: volumeOptions } =
|
const { creators: userOptions, volumes: volumeOptions } =
|
||||||
@@ -121,7 +127,10 @@ const StorageTab: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await queryStorageBreakdown({
|
const data = await queryStorageBreakdown({
|
||||||
...baseRequest(),
|
...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);
|
setChartData(data);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -140,7 +149,7 @@ const StorageTab: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await queryStorageBreakdown({
|
const data = await queryStorageBreakdown({
|
||||||
...baseRequest(),
|
...baseRequest(),
|
||||||
group_by: activeTableTab,
|
group_by: [activeTableTab],
|
||||||
page: tablePage,
|
page: tablePage,
|
||||||
order_by: ORDER_BY_KEY[tableSort.field],
|
order_by: ORDER_BY_KEY[tableSort.field],
|
||||||
descending: tableSort.order === 'descend'
|
descending: tableSort.order === 'descend'
|
||||||
@@ -153,7 +162,14 @@ const StorageTab: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchChart();
|
fetchChart();
|
||||||
}, [dateRange, selectedUsers, selectedVolumes, granularity, refreshKey]);
|
}, [
|
||||||
|
dateRange,
|
||||||
|
selectedUsers,
|
||||||
|
selectedVolumes,
|
||||||
|
granularity,
|
||||||
|
chartGroupBy,
|
||||||
|
refreshKey
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTable();
|
fetchTable();
|
||||||
@@ -208,14 +224,6 @@ const StorageTab: React.FC = () => {
|
|||||||
[summary, coolColors, intl]
|
[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 xAxis = useMemo(() => {
|
||||||
const keys = new Set(
|
const keys = new Set(
|
||||||
generateBucketRange(
|
generateBucketRange(
|
||||||
@@ -224,17 +232,45 @@ const StorageTab: React.FC = () => {
|
|||||||
granularity
|
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();
|
return Array.from(keys).sort();
|
||||||
}, [dataByDate, dateRange, granularity]);
|
}, [chartData, dateRange, granularity]);
|
||||||
|
|
||||||
const seriesData = [
|
const seriesData = useMemo(
|
||||||
{
|
() =>
|
||||||
name: METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric,
|
buildTrendSeries({
|
||||||
data: xAxis.map((d) => dataByDate.get(d) ?? 0),
|
items: chartData?.items,
|
||||||
color: coolColors[0]
|
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 tableColumns = useMemo(() => {
|
||||||
const valueCols = [
|
const valueCols = [
|
||||||
@@ -349,13 +385,13 @@ const StorageTab: React.FC = () => {
|
|||||||
const exportConfig =
|
const exportConfig =
|
||||||
exportMode === 'chart'
|
exportMode === 'chart'
|
||||||
? {
|
? {
|
||||||
groupBy: 'date' as const,
|
groupBy: ['date'],
|
||||||
columns: chartExportColumns,
|
columns: chartExportColumns,
|
||||||
fileName: `storage_chart_${dateSuffix}.xlsx`,
|
fileName: `storage_chart_${dateSuffix}.xlsx`,
|
||||||
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
|
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
groupBy: activeTableTab,
|
groupBy: [activeTableTab],
|
||||||
columns: tableColumns,
|
columns: tableColumns,
|
||||||
fileName: `storage_${activeTableTab}_${dateSuffix}.xlsx`,
|
fileName: `storage_${activeTableTab}_${dateSuffix}.xlsx`,
|
||||||
sheetName: tabLabel || 'storage'
|
sheetName: tabLabel || 'storage'
|
||||||
@@ -413,6 +449,9 @@ const StorageTab: React.FC = () => {
|
|||||||
onGranularityChange={(v) => setGranularity(v as Granularity)}
|
onGranularityChange={(v) => setGranularity(v as Granularity)}
|
||||||
seriesData={seriesData}
|
seriesData={seriesData}
|
||||||
xAxisData={xAxis}
|
xAxisData={xAxis}
|
||||||
|
groupBy={chartGroupBy}
|
||||||
|
groupByOptions={chartGroupByOptions}
|
||||||
|
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ const SummaryTab: React.FC = () => {
|
|||||||
start_date: start,
|
start_date: start,
|
||||||
end_date: end,
|
end_date: end,
|
||||||
scope,
|
scope,
|
||||||
group_by: 'type',
|
group_by: ['type'],
|
||||||
filters: creatorFilter,
|
filters: creatorFilter,
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 100
|
perPage: 100
|
||||||
@@ -284,7 +284,7 @@ const SummaryTab: React.FC = () => {
|
|||||||
start_date: start,
|
start_date: start,
|
||||||
end_date: end,
|
end_date: end,
|
||||||
scope,
|
scope,
|
||||||
group_by: 'date',
|
group_by: ['date'],
|
||||||
granularity,
|
granularity,
|
||||||
filters: creatorFilter,
|
filters: creatorFilter,
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -296,7 +296,7 @@ const SummaryTab: React.FC = () => {
|
|||||||
start_date: start,
|
start_date: start,
|
||||||
end_date: end,
|
end_date: end,
|
||||||
scope,
|
scope,
|
||||||
group_by: 'date',
|
group_by: ['date'],
|
||||||
granularity,
|
granularity,
|
||||||
filters: creatorFilter,
|
filters: creatorFilter,
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user