refactor: usage storage tab, split tables

This commit is contained in:
jialin
2026-06-16 22:28:46 +08:00
committed by jialin
parent df5cf0f4e5
commit 9f5163531c
8 changed files with 323 additions and 239 deletions
@@ -40,6 +40,7 @@ interface MetricOption {
}
interface MetricChartCardProps {
loading?: boolean;
metric: string;
metricOptions: MetricOption[];
granularity: string;
@@ -55,6 +56,7 @@ interface MetricChartCardProps {
}
const MetricChartCard: React.FC<MetricChartCardProps> = ({
loading,
metric,
metricOptions,
granularity,
@@ -129,6 +131,7 @@ const MetricChartCard: React.FC<MetricChartCardProps> = ({
/>
</ControlsWrapper>
<BarChart
loading={loading}
seriesData={seriesData}
xAxisData={xAxisData}
height={280}
+51 -179
View File
@@ -15,12 +15,11 @@ import useCoolColors from '@/hooks/use-cool-colors';
import { formatLargeNumber } from '@/utils';
import { SimpleCard } from '@gpustack/core-ui';
import { useAccess, useIntl } from '@umijs/max';
import { Table, Tabs } from 'antd';
import { Tabs } from 'antd';
import dayjs from 'dayjs';
import React, { useEffect, useMemo, useState } from 'react';
import {
queryStorageBreakdown,
ResourceBreakdownItem,
ResourceBreakdownRequest,
ResourceBreakdownResponse
} from '../apis/resource';
@@ -32,10 +31,11 @@ import useResourceMeta from '../hooks/use-resource-meta';
import {
bucketKey,
generateBucketRange,
Granularity,
parseRollup
Granularity
} from '../utils/time-buckets';
import { buildTrendSeries } from '../utils/trend-series';
import StorageBreakdownTable from './tables/storage-breakdown-table';
import useStorageColumns from './tables/use-storage-columns';
type Scope = 'self' | 'all';
type Metric = 'storage_gb_days' | 'storage_gb_hours';
@@ -63,16 +63,25 @@ const StorageTab: React.FC = () => {
[intl]
);
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(
() => [
{
key: 'volume',
label: intl.formatMessage({ id: 'usage.tabs.storage' })
},
{ key: 'user', label: intl.formatMessage({ id: 'usage.table.users' }) }
],
[intl]
);
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(() => {
return access.canSeeOrgAdmin
? [
{
key: 'volume',
label: intl.formatMessage({ id: 'usage.tabs.storage' })
},
{
key: 'user',
label: intl.formatMessage({ id: 'usage.table.users' })
}
]
: [
{
key: 'volume',
label: intl.formatMessage({ id: 'usage.tabs.storage' })
}
];
}, [intl, access.canSeeOrgAdmin]);
// 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.
@@ -98,15 +107,9 @@ const StorageTab: React.FC = () => {
const [chartData, setChartData] = useState<ResourceBreakdownResponse | null>(
null
);
const [tableData, setTableData] = useState<ResourceBreakdownResponse | null>(
null
);
const [tablePage, setTablePage] = useState(1);
// Server-side sort for the bottom tables; default GB-Days, descending.
const [tableSort, setTableSort] = useState<{
field: Metric;
order: 'ascend' | 'descend';
}>({ field: 'storage_gb_days', order: 'descend' });
// Bumped on any filter change to snap every mounted table back to page 1;
// each table owns its own page/sort state otherwise.
const [pageResetKey, setPageResetKey] = useState(0);
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
start_date: dateRange[0].format('YYYY-MM-DD'),
@@ -142,28 +145,6 @@ const StorageTab: React.FC = () => {
}
};
// The frontend metric keys (storage_gb_days/hours) map to the server's
// breakdown metric keys (gb_days/gb_hours) for order_by.
const ORDER_BY_KEY: Record<Metric, string> = {
storage_gb_days: 'gb_days',
storage_gb_hours: 'gb_hours'
};
const fetchTable = async () => {
try {
const data = await queryStorageBreakdown({
...baseRequest(),
group_by: [activeTableTab],
page: tablePage,
order_by: ORDER_BY_KEY[tableSort.field],
descending: tableSort.order === 'descend'
});
setTableData(data);
} catch {
// Same rationale.
}
};
useEffect(() => {
fetchChart();
}, [
@@ -175,18 +156,6 @@ const StorageTab: React.FC = () => {
refreshKey
]);
useEffect(() => {
fetchTable();
}, [
dateRange,
selectedUsers,
selectedVolumes,
activeTableTab,
tablePage,
tableSort,
refreshKey
]);
const summary = chartData?.summary;
const summaryCards = useMemo(
() => [
@@ -267,86 +236,16 @@ const StorageTab: React.FC = () => {
const chartGroupByOptions = useMemo(
() =>
TABLE_TABS.filter((t) => t.key !== 'user' || scope === 'all').map(
(t) => ({
value: t.key,
label: t.label
})
),
TABLE_TABS.map((t) => ({
value: t.key,
label: t.label
})),
[TABLE_TABS, scope]
);
const tableColumns = useMemo(() => {
const valueCols = [
{
title: intl.formatMessage({ id: 'usage.metric.gbDays' }),
dataIndex: 'storage_gb_days',
key: 'storage_gb_days',
sorter: true,
sortOrder:
tableSort.field === 'storage_gb_days' ? tableSort.order : null,
render: (v: number) => (v ?? 0).toFixed(2)
},
{
title: intl.formatMessage({ id: 'usage.metric.gbHours' }),
dataIndex: 'storage_gb_hours',
key: 'storage_gb_hours',
sorter: true,
sortOrder:
tableSort.field === 'storage_gb_hours' ? tableSort.order : null,
render: (v: number) => (v ?? 0).toFixed(2)
}
];
// Last Active = the last active day. The backend sends a rollup-tz instant
// with its offset; parseRollup keeps that wall clock (no browser-tz convert),
// consistent with the trend chart buckets. Shown date-only.
const lastActiveCol = {
title: intl.formatMessage({ id: 'usage.table.lastActive' }),
dataIndex: 'last_active',
key: 'last_active',
render: (v?: string) => (v ? parseRollup(v).format('YYYY-MM-DD') : '-')
};
if (activeTableTab === 'volume') {
return [
{
title: intl.formatMessage({ id: 'usage.tabs.storage' }),
dataIndex: 'volume_name',
key: 'volume_name'
},
{
title: intl.formatMessage({ id: 'usage.table.type' }),
dataIndex: 'storage_type',
key: 'storage_type',
render: (_v: string, row: ResourceBreakdownItem) =>
row.storage_type || row.gpu_type || '-'
},
{
title: intl.formatMessage({ id: 'usage.table.capacity' }),
dataIndex: 'capacity_mib',
key: 'capacity_mib',
render: (v?: number) => (v ? `${Math.round(v / 1024)}GB` : '-')
},
...valueCols,
lastActiveCol
];
}
return [
{
title: intl.formatMessage({ id: 'usage.table.user' }),
dataIndex: 'user_name',
key: 'user_name'
},
...valueCols,
{
title: intl.formatMessage({ id: 'usage.metric.activeStorage' }),
dataIndex: 'active_volumes',
key: 'active_volumes'
},
lastActiveCol
];
}, [activeTableTab, tableSort, intl]);
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? [];
// 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);
// Export opens a preview modal (matches the Tokens tab): re-filter + preview
// the rows, then download. "Chart" = the by-date trend, "Table" = the active
@@ -397,7 +296,7 @@ const StorageTab: React.FC = () => {
}
: {
groupBy: [activeTableTab],
columns: tableColumns,
columns: exportTableColumns,
fileName: `storage_${activeTableTab}_${dateSuffix}.xlsx`,
sheetName: tabLabel || 'storage'
};
@@ -408,21 +307,21 @@ const StorageTab: React.FC = () => {
value={dateRange}
onChange={(dates) => {
setDateRange(dates);
setTablePage(1);
setPageResetKey((k) => k + 1);
}}
canManageUsers={canManageUsers}
userOptions={userOptions}
selectedUsers={selectedUsers}
onUsersChange={(ids) => {
setSelectedUsers(ids);
setTablePage(1);
setPageResetKey((k) => k + 1);
}}
resourceFilter={{
options: volumeOptions,
value: selectedVolumes,
onChange: (ids) => {
setSelectedVolumes(ids);
setTablePage(1);
setPageResetKey((k) => k + 1);
},
placeholder: intl.formatMessage({ id: 'usage.filter.storage' })
}}
@@ -462,50 +361,23 @@ const StorageTab: React.FC = () => {
<Tabs
activeKey={activeTableTab}
onChange={(k) => {
setActiveTableTab(k as GroupKey);
setTablePage(1);
}}
items={TABLE_TABS.filter(
(t) => t.key !== 'user' || scope === 'all'
).map((t) => ({
onChange={(k) => setActiveTableTab(k as GroupKey)}
items={TABLE_TABS.map((t) => ({
key: t.key,
label: t.label,
// Keep every pane mounted so each table holds its own page/sort and
// switching tabs neither refetches nor resets the other.
forceRender: true,
children: (
<Table
rowKey={(row) =>
`${row.volume_id ?? ''}|${row.user_id ?? ''}|${row.volume_name ?? ''}`
}
dataSource={tableRows}
columns={tableColumns as any}
onChange={(_pagination, _filters, sorter: any) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
// Sort changed → page 1; cleared (3rd click) → default GB-Days
// descending.
const next = s?.order
? {
field: (s.columnKey as Metric) ?? 'storage_gb_days',
order: s.order as 'ascend' | 'descend'
}
: {
field: 'storage_gb_days' as Metric,
order: 'descend' as const
};
if (
next.field !== tableSort.field ||
next.order !== tableSort.order
) {
setTableSort(next);
setTablePage(1);
}
}}
pagination={{
size: 'middle',
current: tablePage,
pageSize: tableData?.pagination.perPage ?? 50,
total: tableData?.pagination.total ?? 0,
onChange: (p) => setTablePage(p)
}}
<StorageBreakdownTable
key={t.key}
groupKey={t.key}
dateRange={dateRange}
scope={scope}
selectedUsers={selectedUsers}
selectedVolumes={selectedVolumes}
pageResetKey={pageResetKey}
refreshKey={refreshKey}
/>
)
}))}
@@ -0,0 +1,29 @@
import { useQueryData } from '@/hooks/use-query-data-list';
import {
queryStorageBreakdown,
ResourceBreakdownRequest,
ResourceBreakdownResponse
} from '../../apis/resource';
/**
* Wraps the `queryStorageBreakdown` request with shared loading state and
* in-flight cancellation (previous request is cancelled on each new fetch and
* on unmount), so rapid filter/page/sort changes can't race a stale response
* onto the table.
*/
export default function useQueryStorageBreakdown(option?: { key?: string }) {
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ResourceBreakdownResponse,
ResourceBreakdownRequest
>({
fetchDetail: queryStorageBreakdown,
key: option?.key || 'storageBreakdownTable'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,157 @@
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import { Table } from 'antd';
import dayjs from 'dayjs';
import React, { useEffect, useRef, useState } from 'react';
import { ResourceBreakdownItem } from '../../apis/resource';
import useQueryStorageBreakdown from '../services/use-query-storage-breakdown';
import useStorageColumns from './use-storage-columns';
type Scope = 'self' | 'all';
type Metric = 'storage_gb_days' | 'storage_gb_hours';
type GroupKey = 'volume' | 'user';
// The frontend metric keys (storage_gb_days/hours) map to the server's
// breakdown metric keys (gb_days/gb_hours).
const ORDER_BY_KEY: Record<Metric, string> = {
storage_gb_days: 'gb_days',
storage_gb_hours: 'gb_hours'
};
const PER_PAGE = 50;
// sort_by encodes order as a string (`-` prefix = descending), so the fetch
// effect dedupes naturally on the primitive. Default: GB-Days descending.
const DEFAULT_SORT = '-gb_days';
interface Props {
groupKey: GroupKey;
dateRange: [dayjs.Dayjs, dayjs.Dayjs];
scope: Scope;
selectedUsers: number[];
selectedVolumes: number[];
// Bumped by the parent when a filter changes, so each mounted table snaps
// back to page 1 independently.
pageResetKey?: number;
refreshKey?: number;
}
/**
* One storage breakdown table, owning its own page/sort/data. Both tabs mount
* an instance (volume / user) and keep it alive (Tabs `forceRender`), so each
* keeps its own pagination + sort and switching tabs neither refetches nor
* resets the other.
*/
const StorageBreakdownTable: React.FC<Props> = ({
groupKey,
dateRange,
scope,
selectedUsers,
selectedVolumes,
pageResetKey = 0,
refreshKey = 0
}) => {
const [queryParams, setQueryParams] = useState<{
page: number;
perPage: number;
sort_by: string;
}>({ page: 1, perPage: PER_PAGE, sort_by: DEFAULT_SORT });
const pendingPageResetRef = useRef(false);
const { detailData, loading, fetchData } = useQueryStorageBreakdown({
key: `storageBreakdown-${groupKey}`
});
const columns = useStorageColumns(groupKey);
// Sort only — paging goes through handlePageChange, so this stays
// page-agnostic and the two compose (matches the Tokens tab tables).
const handleTableChange = (_pagination: any, _filters: any, sorter: any) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
const field = ORDER_BY_KEY[s?.field as Metric];
// Cleared (3rd click) or an unknown column → default GB-Days descending.
const sort_by =
!field || !s?.order
? DEFAULT_SORT
: s.order === 'ascend'
? field
: `-${field}`;
// Only a real sort change snaps back to page 1; an unchanged sort_by
// (e.g. the onChange that fires alongside a page click) is a no-op, so it
// doesn't fight handlePageChange.
setQueryParams((prev) =>
prev.sort_by === sort_by ? prev : { ...prev, sort_by, page: 1 }
);
};
const handlePageChange = (page: number, pageSize: number) => {
setQueryParams((prev) => ({ ...prev, page, perPage: pageSize }));
};
// Filters changed upstream → snap back to page 1. Defer the fetch until the
// page-1 render so we don't fire a stale page-N request first.
useEffect(() => {
if (queryParams.page !== 1) {
pendingPageResetRef.current = true;
setQueryParams((prev) => ({ ...prev, page: 1 }));
}
}, [pageResetKey]);
useEffect(() => {
if (pendingPageResetRef.current && queryParams.page !== 1) return;
pendingPageResetRef.current = false;
const descending = queryParams.sort_by.startsWith('-');
fetchData({
start_date: dateRange[0].format('YYYY-MM-DD'),
end_date: dateRange[1].format('YYYY-MM-DD'),
scope,
filters:
selectedUsers.length || selectedVolumes.length
? {
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
...(selectedVolumes.length ? { volume_ids: selectedVolumes } : {})
}
: undefined,
group_by: [groupKey],
page: queryParams.page,
perPage: queryParams.perPage,
order_by: descending ? queryParams.sort_by.slice(1) : queryParams.sort_by,
descending
});
}, [
dateRange,
scope,
selectedUsers,
selectedVolumes,
queryParams.page,
queryParams.perPage,
queryParams.sort_by,
refreshKey
]);
const rows: ResourceBreakdownItem[] = detailData?.items ?? [];
return (
<Table
rowKey={(row) =>
`${row.volume_id ?? ''}|${row.user_id ?? ''}|${row.volume_name ?? ''}`
}
dataSource={rows}
columns={columns as any}
loading={{ spinning: loading, size: 'middle' }}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
onChange={handleTableChange}
pagination={{
size: 'middle',
current: queryParams.page,
pageSize: detailData?.pagination?.perPage ?? queryParams.perPage,
total: detailData?.pagination?.total ?? 0,
showSizeChanger: true,
hideOnSinglePage: queryParams.perPage === PER_PAGE,
onChange: handlePageChange
}}
/>
);
};
export default StorageBreakdownTable;
@@ -0,0 +1,83 @@
import { useIntl } from '@umijs/max';
import { useMemo } from 'react';
import { ResourceBreakdownItem } from '../../apis/resource';
import { parseRollup } from '../../utils/time-buckets';
type GroupKey = 'volume' | 'user';
/**
* Column factory for the storage breakdown tables, shared by the in-tab table
* and the export preview. Sort indicators are uncontrolled (antd manages the
* header arrows); the table reports changes through its `onChange`.
*/
const useStorageColumns = (groupKey: GroupKey) => {
const intl = useIntl();
return useMemo(() => {
const valueCols = [
{
title: intl.formatMessage({ id: 'usage.metric.gbDays' }),
dataIndex: 'storage_gb_days',
key: 'storage_gb_days',
sorter: true,
render: (v: number) => (v ?? 0).toFixed(2)
},
{
title: intl.formatMessage({ id: 'usage.metric.gbHours' }),
dataIndex: 'storage_gb_hours',
key: 'storage_gb_hours',
sorter: true,
render: (v: number) => (v ?? 0).toFixed(2)
}
];
// Last Active = the last active day. The backend sends a rollup-tz instant
// with its offset; parseRollup keeps that wall clock (no browser-tz convert),
// consistent with the trend chart buckets. Shown date-only.
const lastActiveCol = {
title: intl.formatMessage({ id: 'usage.table.lastActive' }),
dataIndex: 'last_active',
key: 'last_active',
render: (v?: string) => (v ? parseRollup(v).format('YYYY-MM-DD') : '-')
};
if (groupKey === 'volume') {
return [
{
title: intl.formatMessage({ id: 'usage.tabs.storage' }),
dataIndex: 'volume_name',
key: 'volume_name'
},
{
title: intl.formatMessage({ id: 'usage.table.type' }),
dataIndex: 'storage_type',
key: 'storage_type',
render: (_v: string, row: ResourceBreakdownItem) =>
row.storage_type || row.gpu_type || '-'
},
{
title: intl.formatMessage({ id: 'usage.table.capacity' }),
dataIndex: 'capacity_mib',
key: 'capacity_mib',
render: (v?: number) => (v ? `${Math.round(v / 1024)}GB` : '-')
},
...valueCols,
lastActiveCol
];
}
return [
{
title: intl.formatMessage({ id: 'usage.table.user' }),
dataIndex: 'user_name',
key: 'user_name'
},
...valueCols,
{
title: intl.formatMessage({ id: 'usage.metric.activeStorage' }),
dataIndex: 'active_volumes',
key: 'active_volumes'
},
lastActiveCol
];
}, [groupKey, intl]);
};
export default useStorageColumns;
@@ -1,9 +1,7 @@
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import PageBox from '@/pages/_components/page-box';
import { IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Table } from 'antd';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { FilterOptionType } from '../../config/types';
import useAPIKeys from '../../hooks/use-apikeys-columns';
@@ -61,24 +59,6 @@ const APIKeys: React.FC<{
}
}, [pageResetKey]);
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-key" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.keys.nofound'
})}
title={intl.formatMessage({ id: 'noresult.keys.title' })}
subTitle={intl.formatMessage({ id: 'noresult.keys.subTitle' })}
></NoResult>
);
};
useEffect(() => {
if (pendingPageResetRef.current && queryParams.page !== 1) {
return;
@@ -1,10 +1,8 @@
import PluginExtraFields from '@/components/plugin-extra-fields';
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import PageBox from '@/pages/_components/page-box';
import { IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Table } from 'antd';
import _ from 'lodash';
import { useEffect, useMemo, useRef, useState } from 'react';
import { BreakdownItem, FilterOptionType } from '../../config/types';
import useModelsColumns from '../../hooks/use-models-columns';
@@ -64,24 +62,6 @@ const Models: React.FC<{
}
}, [pageResetKey]);
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-resources" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.mymodels.nofound'
})}
title={intl.formatMessage({ id: 'noresult.deployments.title' })}
subTitle={intl.formatMessage({ id: 'noresult.deployments.subTitle' })}
></NoResult>
);
};
useEffect(() => {
if (pendingPageResetRef.current && queryParams.page !== 1) {
return;
@@ -1,9 +1,7 @@
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import PageBox from '@/pages/_components/page-box';
import { IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Table } from 'antd';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { FilterOptionType } from '../../config/types';
import useUsersColumns from '../../hooks/use-users-columns';
@@ -52,24 +50,6 @@ const Users: React.FC<{
const columns = useUsersColumns();
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-users" />}
filters={_.omit(queryParams, ['sort_by'])}
noFoundText={intl.formatMessage({
id: 'noresult.users.nofound'
})}
title={intl.formatMessage({ id: 'noresult.users.title' })}
subTitle={intl.formatMessage({ id: 'noresult.users.subTitle' })}
></NoResult>
);
};
useEffect(() => {
if (queryParams.page !== 1) {
pendingPageResetRef.current = true;