From 9f5163531c976be1e05ca9499836a2ebf821cabf Mon Sep 17 00:00:00 2001 From: jialin Date: Tue, 16 Jun 2026 17:05:33 +0800 Subject: [PATCH] refactor: usage storage tab, split tables --- .../usage/components/metric-chart-card.tsx | 3 + src/pages/usage/storage-tab/index.tsx | 230 ++++-------------- .../services/use-query-storage-breakdown.ts | 29 +++ .../tables/storage-breakdown-table.tsx | 157 ++++++++++++ .../tables/use-storage-columns.tsx | 83 +++++++ .../usage/token-tab/tables/apikeys-table.tsx | 20 -- .../usage/token-tab/tables/models-table.tsx | 20 -- .../usage/token-tab/tables/users-table.tsx | 20 -- 8 files changed, 323 insertions(+), 239 deletions(-) create mode 100644 src/pages/usage/storage-tab/services/use-query-storage-breakdown.ts create mode 100644 src/pages/usage/storage-tab/tables/storage-breakdown-table.tsx create mode 100644 src/pages/usage/storage-tab/tables/use-storage-columns.tsx diff --git a/src/pages/usage/components/metric-chart-card.tsx b/src/pages/usage/components/metric-chart-card.tsx index 8790ae5f..c06ab9b9 100644 --- a/src/pages/usage/components/metric-chart-card.tsx +++ b/src/pages/usage/components/metric-chart-card.tsx @@ -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 = ({ + loading, metric, metricOptions, granularity, @@ -129,6 +131,7 @@ const MetricChartCard: 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( null ); - const [tableData, setTableData] = useState( - 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 => ({ 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 = { - 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 = () => { { - 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: ( - - `${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) - }} + ) }))} diff --git a/src/pages/usage/storage-tab/services/use-query-storage-breakdown.ts b/src/pages/usage/storage-tab/services/use-query-storage-breakdown.ts new file mode 100644 index 00000000..d79fd507 --- /dev/null +++ b/src/pages/usage/storage-tab/services/use-query-storage-breakdown.ts @@ -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 + }; +} diff --git a/src/pages/usage/storage-tab/tables/storage-breakdown-table.tsx b/src/pages/usage/storage-tab/tables/storage-breakdown-table.tsx new file mode 100644 index 00000000..13ce224d --- /dev/null +++ b/src/pages/usage/storage-tab/tables/storage-breakdown-table.tsx @@ -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 = { + 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 = ({ + 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 ( +
+ `${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; diff --git a/src/pages/usage/storage-tab/tables/use-storage-columns.tsx b/src/pages/usage/storage-tab/tables/use-storage-columns.tsx new file mode 100644 index 00000000..5a3f2f5c --- /dev/null +++ b/src/pages/usage/storage-tab/tables/use-storage-columns.tsx @@ -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; diff --git a/src/pages/usage/token-tab/tables/apikeys-table.tsx b/src/pages/usage/token-tab/tables/apikeys-table.tsx index 20cc674b..de115dca 100644 --- a/src/pages/usage/token-tab/tables/apikeys-table.tsx +++ b/src/pages/usage/token-tab/tables/apikeys-table.tsx @@ -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 ( - } - 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' })} - > - ); - }; - useEffect(() => { if (pendingPageResetRef.current && queryParams.page !== 1) { return; diff --git a/src/pages/usage/token-tab/tables/models-table.tsx b/src/pages/usage/token-tab/tables/models-table.tsx index ac555a89..f15ee997 100644 --- a/src/pages/usage/token-tab/tables/models-table.tsx +++ b/src/pages/usage/token-tab/tables/models-table.tsx @@ -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 ( - } - 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' })} - > - ); - }; - useEffect(() => { if (pendingPageResetRef.current && queryParams.page !== 1) { return; diff --git a/src/pages/usage/token-tab/tables/users-table.tsx b/src/pages/usage/token-tab/tables/users-table.tsx index e8d7d867..1b5793c1 100644 --- a/src/pages/usage/token-tab/tables/users-table.tsx +++ b/src/pages/usage/token-tab/tables/users-table.tsx @@ -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 ( - } - 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' })} - > - ); - }; - useEffect(() => { if (queryParams.page !== 1) { pendingPageResetRef.current = true;