diff --git a/src/pages/usage/instances-tab/index.tsx b/src/pages/usage/instances-tab/index.tsx
index 48583c6b..7de4009d 100644
--- a/src/pages/usage/instances-tab/index.tsx
+++ b/src/pages/usage/instances-tab/index.tsx
@@ -25,6 +25,10 @@ import MetricLabel from '../components/metric-label';
import ResourceExportData from '../components/resource-export-data';
import ResourceFilterBar from '../components/resource-filter-bar';
import useResourceMeta from '../hooks/use-resource-meta';
+import {
+ exportBreakdownSheets,
+ toExportColumns
+} from '../utils/export-breakdown';
import {
bucketKey,
generateBucketRange,
@@ -245,18 +249,67 @@ const GpuInstancesTab: React.FC = () => {
[TABLE_TABS]
);
- // Columns for the export preview of the active tab (sort arrows omitted —
- // the in-tab table owns its own sort state). Same factory the tables use.
- const exportTableColumns = useInstancesColumns(activeTableTab);
+ // Columns for each bottom-table grouping — same factory the tables render
+ // with — used to build the export sheets below.
+ const gpuTypeColumns = useInstancesColumns('gpu_type');
+ const instanceColumns = useInstancesColumns('instance');
+ const userColumns = useInstancesColumns('user');
- // 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);
+ // "Export Table Data" writes every bottom table at once — one sheet per
+ // grouping (Instance Types / Instances / Users) — straight to a workbook,
+ // no preview dialog (mirrors the Tokens tab's `useExportTable`). The User
+ // sheet is included only when the org-wide view is available.
+ const tableExportGroups = useMemo(() => {
+ const groups = [
+ {
+ key: 'gpu_type' as GroupKey,
+ columns: gpuTypeColumns,
+ sheetName: intl.formatMessage({ id: 'usage.table.instanceTypes' })
+ },
+ {
+ key: 'instance' as GroupKey,
+ columns: instanceColumns,
+ sheetName: intl.formatMessage({ id: 'usage.table.instances' })
+ }
+ ];
+ if (canManageUsers) {
+ groups.push({
+ key: 'user' as GroupKey,
+ columns: userColumns,
+ sheetName: intl.formatMessage({ id: 'usage.table.users' })
+ });
+ }
+ return groups;
+ }, [gpuTypeColumns, instanceColumns, userColumns, canManageUsers, intl]);
+
+ // Chart export still opens the preview modal (matches the Tokens tab); the
+ // table export is direct, so this only ever holds 'chart'.
+ const [exportMode, setExportMode] = useState<'chart' | null>(null);
const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format(
'YYYY-MM-DD'
)}`;
+ const handleExportTable = async () => {
+ const results = await Promise.all(
+ tableExportGroups.map((g) =>
+ queryGpuInstancesBreakdown({
+ ...baseRequest(),
+ group_by: [g.key],
+ // A breakdown export is the full filtered set, not a page.
+ perPage: 10000
+ })
+ )
+ );
+ exportBreakdownSheets(
+ tableExportGroups.map((g, i) => ({
+ rows: results[i]?.items ?? [],
+ columns: toExportColumns(g.columns),
+ sheetName: g.sheetName
+ })),
+ `gpu-instances_tables_${dateSuffix}.xlsx`
+ );
+ };
+
const chartExportColumns = [
{
title: intl.formatMessage({ id: 'usage.table.date' }),
@@ -287,21 +340,13 @@ const GpuInstancesTab: React.FC = () => {
}
];
- 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: exportTableColumns,
- fileName: `gpu-instances_${activeTableTab}_${dateSuffix}.xlsx`,
- sheetName: tabLabel || 'gpu-instances'
- };
+ // The preview modal now only backs the by-date chart export.
+ const exportConfig = {
+ groupBy: ['date'],
+ columns: chartExportColumns,
+ fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
+ sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
+ };
return (
@@ -330,7 +375,7 @@ const GpuInstancesTab: React.FC = () => {
}}
onRefresh={() => setRefreshKey((k) => k + 1)}
onExportChart={() => setExportMode('chart')}
- onExportTable={() => setExportMode('table')}
+ onExportTable={handleExportTable}
/>
{/* KPI cards */}
@@ -349,7 +394,7 @@ const GpuInstancesTab: React.FC = () => {
{/* Daily trend chart */}
-
+
{
setExportMode(null)}
- title={
- exportMode === 'chart'
- ? intl.formatMessage({ id: 'usage.export.chart' })
- : intl.formatMessage(
- { id: 'usage.export.tableNamed' },
- { name: tabLabel }
- )
- }
+ title={intl.formatMessage({ id: 'usage.export.chart' })}
queryFn={queryGpuInstancesBreakdown}
groupBy={exportConfig.groupBy}
columns={exportConfig.columns}
diff --git a/src/pages/usage/instances-tab/tables/instances-breakdown-table.tsx b/src/pages/usage/instances-tab/tables/instances-breakdown-table.tsx
index 4f05b39d..83aaf020 100644
--- a/src/pages/usage/instances-tab/tables/instances-breakdown-table.tsx
+++ b/src/pages/usage/instances-tab/tables/instances-breakdown-table.tsx
@@ -123,7 +123,8 @@ const InstancesBreakdownTable: React.FC = ({
queryParams.page,
queryParams.perPage,
queryParams.sort_by,
- refreshKey
+ refreshKey,
+ fetchData
]);
const rows: ResourceBreakdownItem[] = detailData?.items ?? [];
diff --git a/src/pages/usage/storage-tab/index.tsx b/src/pages/usage/storage-tab/index.tsx
index 28fd0f50..b613b9fb 100644
--- a/src/pages/usage/storage-tab/index.tsx
+++ b/src/pages/usage/storage-tab/index.tsx
@@ -28,6 +28,10 @@ import MetricLabel from '../components/metric-label';
import ResourceExportData from '../components/resource-export-data';
import ResourceFilterBar from '../components/resource-filter-bar';
import useResourceMeta from '../hooks/use-resource-meta';
+import {
+ exportBreakdownSheets,
+ toExportColumns
+} from '../utils/export-breakdown';
import {
bucketKey,
generateBucketRange,
@@ -238,18 +242,61 @@ const StorageTab: React.FC = () => {
[TABLE_TABS, scope]
);
- // Columns for the export preview of the active tab (sort arrows omitted —
- // the in-tab table owns its own sort state). Same factory the tables use.
- const exportTableColumns = useStorageColumns(activeTableTab);
+ // Columns for each bottom-table grouping — same factory the tables render
+ // with — used to build the export sheets below.
+ const volumeColumns = useStorageColumns('volume');
+ const userColumns = useStorageColumns('user');
- // 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);
+ // "Export Table Data" writes every bottom table at once — one sheet per
+ // grouping (Storage / Users) — straight to a workbook, no preview dialog
+ // (mirrors the Tokens tab's `useExportTable`). The User sheet is included
+ // only when the org-wide view is available.
+ const tableExportGroups = useMemo(() => {
+ const groups = [
+ {
+ key: 'volume' as GroupKey,
+ columns: volumeColumns,
+ sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
+ }
+ ];
+ if (canManageUsers) {
+ groups.push({
+ key: 'user' as GroupKey,
+ columns: userColumns,
+ sheetName: intl.formatMessage({ id: 'usage.table.users' })
+ });
+ }
+ return groups;
+ }, [volumeColumns, userColumns, canManageUsers, intl]);
+
+ // Chart export still opens the preview modal (matches the Tokens tab); the
+ // table export is direct, so this only ever holds 'chart'.
+ const [exportMode, setExportMode] = useState<'chart' | null>(null);
const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format(
'YYYY-MM-DD'
)}`;
+ const handleExportTable = async () => {
+ const results = await Promise.all(
+ tableExportGroups.map((g) =>
+ queryStorageBreakdown({
+ ...baseRequest(),
+ group_by: [g.key],
+ // A breakdown export is the full filtered set, not a page.
+ perPage: 10000
+ })
+ )
+ );
+ exportBreakdownSheets(
+ tableExportGroups.map((g, i) => ({
+ rows: results[i]?.items ?? [],
+ columns: toExportColumns(g.columns),
+ sheetName: g.sheetName
+ })),
+ `storage_tables_${dateSuffix}.xlsx`
+ );
+ };
+
const chartExportColumns = [
{
title: intl.formatMessage({ id: 'usage.table.date' }),
@@ -280,21 +327,13 @@ const StorageTab: React.FC = () => {
}
];
- const tabLabel = TABLE_TABS.find((t) => t.key === activeTableTab)?.label;
- const exportConfig =
- exportMode === 'chart'
- ? {
- groupBy: ['date'],
- columns: chartExportColumns,
- fileName: `storage_chart_${dateSuffix}.xlsx`,
- sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
- }
- : {
- groupBy: [activeTableTab],
- columns: exportTableColumns,
- fileName: `storage_${activeTableTab}_${dateSuffix}.xlsx`,
- sheetName: tabLabel || 'storage'
- };
+ // The preview modal now only backs the by-date chart export.
+ const exportConfig = {
+ groupBy: ['date'],
+ columns: chartExportColumns,
+ fileName: `storage_chart_${dateSuffix}.xlsx`,
+ sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
+ };
return (
@@ -322,7 +361,7 @@ const StorageTab: React.FC = () => {
}}
onRefresh={() => setRefreshKey((k) => k + 1)}
onExportChart={() => setExportMode('chart')}
- onExportTable={() => setExportMode('table')}
+ onExportTable={handleExportTable}
/>
@@ -382,14 +421,7 @@ const StorageTab: React.FC = () => {
setExportMode(null)}
- title={
- exportMode === 'chart'
- ? intl.formatMessage({ id: 'usage.export.chart' })
- : intl.formatMessage(
- { id: 'usage.export.tableNamed' },
- { name: tabLabel }
- )
- }
+ title={intl.formatMessage({ id: 'usage.export.chart' })}
queryFn={queryStorageBreakdown}
groupBy={exportConfig.groupBy}
columns={exportConfig.columns}
diff --git a/src/pages/usage/storage-tab/tables/storage-breakdown-table.tsx b/src/pages/usage/storage-tab/tables/storage-breakdown-table.tsx
index 13ce224d..9c4a9ded 100644
--- a/src/pages/usage/storage-tab/tables/storage-breakdown-table.tsx
+++ b/src/pages/usage/storage-tab/tables/storage-breakdown-table.tsx
@@ -125,7 +125,8 @@ const StorageBreakdownTable: React.FC = ({
queryParams.page,
queryParams.perPage,
queryParams.sort_by,
- refreshKey
+ refreshKey,
+ fetchData
]);
const rows: ResourceBreakdownItem[] = detailData?.items ?? [];
diff --git a/src/pages/usage/utils/export-breakdown.ts b/src/pages/usage/utils/export-breakdown.ts
index 93dcc30d..6cb77a39 100644
--- a/src/pages/usage/utils/export-breakdown.ts
+++ b/src/pages/usage/utils/export-breakdown.ts
@@ -26,12 +26,13 @@ export const toExportColumns = (columns: any[]): ExportColumn[] =>
dataIndex: c.dataIndex as string
}));
-export const exportBreakdownRows = (
- rows: any[],
- columns: ExportColumn[],
- fileName: string,
- sheetName = 'usage'
-): void => {
+export interface ExportSheet {
+ rows: any[];
+ columns: ExportColumn[];
+ sheetName: string;
+}
+
+const buildSheet = ({ rows, columns, sheetName }: ExportSheet) => {
const fields = columns.map((c) => c.dataIndex);
const fieldLabels = Object.fromEntries(
columns.map((c) => [c.dataIndex, c.title])
@@ -43,8 +44,22 @@ export const exportBreakdownRows = (
});
return o;
});
- exportJsonToExcel({
- fileName,
- sheets: [{ jsonData, sheetName, fields, fieldLabels, formatMap: {} }]
- });
+ return { jsonData, sheetName, fields, fieldLabels, formatMap: {} };
+};
+
+// Write one or more breakdown result sets to a single workbook, one sheet each.
+export const exportBreakdownSheets = (
+ sheets: ExportSheet[],
+ fileName: string
+): void => {
+ exportJsonToExcel({ fileName, sheets: sheets.map(buildSheet) });
+};
+
+export const exportBreakdownRows = (
+ rows: any[],
+ columns: ExportColumn[],
+ fileName: string,
+ sheetName = 'usage'
+): void => {
+ exportBreakdownSheets([{ rows, columns, sheetName }], fileName);
};