refactor: usage instance tab, split tables
This commit is contained in:
@@ -10,35 +10,30 @@
|
|||||||
* Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints.
|
* Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints.
|
||||||
*/
|
*/
|
||||||
import useCoolColors from '@/hooks/use-cool-colors';
|
import useCoolColors from '@/hooks/use-cool-colors';
|
||||||
import {
|
|
||||||
buildInstanceTypeRecordFromMiB,
|
|
||||||
renderInstanceType
|
|
||||||
} from '@/pages/gpu-service/instances/utils/render-instance-type';
|
|
||||||
import { formatLargeNumber } from '@/utils';
|
import { formatLargeNumber } from '@/utils';
|
||||||
import { SimpleCard } from '@gpustack/core-ui';
|
import { SimpleCard } from '@gpustack/core-ui';
|
||||||
import { useAccess, useIntl } from '@umijs/max';
|
import { useAccess, useIntl } from '@umijs/max';
|
||||||
import { Table, Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
queryGpuInstancesBreakdown,
|
queryGpuInstancesBreakdown,
|
||||||
ResourceBreakdownItem,
|
ResourceBreakdownRequest
|
||||||
ResourceBreakdownRequest,
|
|
||||||
ResourceBreakdownResponse
|
|
||||||
} from '../apis/resource';
|
} from '../apis/resource';
|
||||||
import MetricChartCard from '../components/metric-chart-card';
|
import MetricChartCard from '../components/metric-chart-card';
|
||||||
import MetricLabel from '../components/metric-label';
|
import MetricLabel from '../components/metric-label';
|
||||||
import ResourceExportData from '../components/resource-export-data';
|
import ResourceExportData from '../components/resource-export-data';
|
||||||
import ResourceFilterBar from '../components/resource-filter-bar';
|
import ResourceFilterBar from '../components/resource-filter-bar';
|
||||||
import useResourceMeta from '../hooks/use-resource-meta';
|
import useResourceMeta from '../hooks/use-resource-meta';
|
||||||
import { instanceTypeLabel } from '../utils/format-instance-type';
|
|
||||||
import {
|
import {
|
||||||
bucketKey,
|
bucketKey,
|
||||||
generateBucketRange,
|
generateBucketRange,
|
||||||
Granularity,
|
Granularity
|
||||||
parseRollup
|
|
||||||
} from '../utils/time-buckets';
|
} from '../utils/time-buckets';
|
||||||
import { buildTrendSeries } from '../utils/trend-series';
|
import { buildTrendSeries } from '../utils/trend-series';
|
||||||
|
import useQueryGpuInstancesBreakdown from './services/use-query-gpu-instances-breakdown';
|
||||||
|
import InstancesBreakdownTable from './tables/instances-breakdown-table';
|
||||||
|
import useInstancesColumns from './tables/use-instances-columns';
|
||||||
|
|
||||||
type Scope = 'self' | 'all';
|
type Scope = 'self' | 'all';
|
||||||
type Metric = 'gpu_hours' | 'instance_hours';
|
type Metric = 'gpu_hours' | 'instance_hours';
|
||||||
@@ -62,20 +57,26 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
[intl]
|
[intl]
|
||||||
);
|
);
|
||||||
|
|
||||||
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(
|
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(() => {
|
||||||
() => [
|
const tabs = [
|
||||||
{
|
{
|
||||||
key: 'gpu_type',
|
key: 'gpu_type' as GroupKey,
|
||||||
label: intl.formatMessage({ id: 'usage.table.instanceTypes' })
|
label: intl.formatMessage({ id: 'usage.table.instanceTypes' })
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'instance',
|
key: 'instance' as GroupKey,
|
||||||
label: intl.formatMessage({ id: 'usage.table.instances' })
|
label: intl.formatMessage({ id: 'usage.table.instances' })
|
||||||
},
|
}
|
||||||
{ key: 'user', label: intl.formatMessage({ id: 'usage.table.users' }) }
|
];
|
||||||
],
|
// Managers see the org-wide User breakdown; members only their own rows.
|
||||||
[intl]
|
if (access.canSeeOrgAdmin) {
|
||||||
);
|
tabs.push({
|
||||||
|
key: 'user' as GroupKey,
|
||||||
|
label: intl.formatMessage({ id: 'usage.table.users' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tabs;
|
||||||
|
}, [intl, access.canSeeOrgAdmin]);
|
||||||
// ``useCoolColors`` returns a memoized factory; resolve a fixed 5-slot
|
// ``useCoolColors`` returns a memoized factory; resolve a fixed 5-slot
|
||||||
// palette for the KPI cards, and keep the factory for the grouped trend
|
// palette for the KPI cards, and keep the factory for the grouped trend
|
||||||
// (sized to the group count).
|
// (sized to the group count).
|
||||||
@@ -105,21 +106,16 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
const { creators: userOptions, instances: instanceOptions } =
|
const { creators: userOptions, instances: instanceOptions } =
|
||||||
useResourceMeta(scope);
|
useResourceMeta(scope);
|
||||||
|
|
||||||
// Two independent fetches: one for the daily chart (group_by=date),
|
// The daily chart fetches group_by=date here; each bottom table owns its own
|
||||||
// one for the table (group_by=tab key). Both reuse the same date /
|
// fetch (group_by=tab key) inside InstancesBreakdownTable. Bumped on any
|
||||||
// scope filters so the views stay in sync.
|
// filter change to snap every mounted table back to page 1.
|
||||||
const [chartData, setChartData] = useState<ResourceBreakdownResponse | null>(
|
const [pageResetKey, setPageResetKey] = useState(0);
|
||||||
null
|
|
||||||
);
|
const {
|
||||||
const [tableData, setTableData] = useState<ResourceBreakdownResponse | null>(
|
detailData: chartData,
|
||||||
null
|
loading: chartLoading,
|
||||||
);
|
fetchData: fetchChartData
|
||||||
const [tablePage, setTablePage] = useState(1);
|
} = useQueryGpuInstancesBreakdown({ key: 'gpuInstancesBreakdownChart' });
|
||||||
// Server-side sort for the bottom tables; default GPU Hours, descending.
|
|
||||||
const [tableSort, setTableSort] = useState<{
|
|
||||||
field: Metric;
|
|
||||||
order: 'ascend' | 'descend';
|
|
||||||
}>({ field: 'gpu_hours', order: 'descend' });
|
|
||||||
|
|
||||||
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
|
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
|
||||||
start_date: dateRange[0].format('YYYY-MM-DD'),
|
start_date: dateRange[0].format('YYYY-MM-DD'),
|
||||||
@@ -139,39 +135,17 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
perPage: 50
|
perPage: 50
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchChart = async () => {
|
const fetchChart = () =>
|
||||||
try {
|
fetchChartData({
|
||||||
const data = await queryGpuInstancesBreakdown({
|
...baseRequest(),
|
||||||
...baseRequest(),
|
// Split each bucket by the chosen dimension when grouping.
|
||||||
// Split each bucket by the chosen dimension when grouping.
|
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
|
||||||
group_by: chartGroupBy ? ['date', chartGroupBy] : ['date'],
|
// A trend is a time series, not a paginated table: always fetch the
|
||||||
// A trend is a time series, not a paginated table: always fetch the
|
// whole range. The default order is metric-desc, so partial (current/
|
||||||
// whole range. The default order is metric-desc, so partial (current/
|
// recent) buckets have smaller values and would be pushed onto later
|
||||||
// recent) buckets have smaller values and would be pushed onto later
|
// pages — dropping the newest hours from the chart under a small page.
|
||||||
// pages — dropping the newest hours from the chart under a small page.
|
perPage: 10000
|
||||||
perPage: 10000
|
});
|
||||||
});
|
|
||||||
setChartData(data);
|
|
||||||
} catch {
|
|
||||||
// Network/auth errors surface via the global request interceptor;
|
|
||||||
// keep the previous chart so the UI doesn't flash empty.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchTable = async () => {
|
|
||||||
try {
|
|
||||||
const data = await queryGpuInstancesBreakdown({
|
|
||||||
...baseRequest(),
|
|
||||||
group_by: [activeTableTab],
|
|
||||||
page: tablePage,
|
|
||||||
order_by: tableSort.field,
|
|
||||||
descending: tableSort.order === 'descend'
|
|
||||||
});
|
|
||||||
setTableData(data);
|
|
||||||
} catch {
|
|
||||||
// Same rationale as fetchChart.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchChart();
|
fetchChart();
|
||||||
@@ -184,18 +158,6 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
refreshKey
|
refreshKey
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchTable();
|
|
||||||
}, [
|
|
||||||
dateRange,
|
|
||||||
selectedUsers,
|
|
||||||
selectedInstances,
|
|
||||||
activeTableTab,
|
|
||||||
tablePage,
|
|
||||||
tableSort,
|
|
||||||
refreshKey
|
|
||||||
]);
|
|
||||||
|
|
||||||
// KPI summary cards — pull from the chart summary since both queries
|
// KPI summary cards — pull from the chart summary since both queries
|
||||||
// return the same scope-wide totals.
|
// return the same scope-wide totals.
|
||||||
const summary = chartData?.summary;
|
const summary = chartData?.summary;
|
||||||
@@ -273,116 +235,19 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Group-by options for the trend = the same dimensions as the bottom tables
|
// Group-by options for the trend = the same dimensions as the bottom tables
|
||||||
// (Users only when org-wide, matching the table tabs).
|
// (TABLE_TABS already gates Users to org admins).
|
||||||
const chartGroupByOptions = useMemo(
|
const chartGroupByOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
TABLE_TABS.filter((t) => t.key !== 'user' || scope === 'all').map(
|
TABLE_TABS.map((t) => ({
|
||||||
(t) => ({
|
value: t.key,
|
||||||
value: t.key,
|
label: t.label
|
||||||
label: t.label
|
})),
|
||||||
})
|
[TABLE_TABS]
|
||||||
),
|
|
||||||
[TABLE_TABS, scope]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Table columns adapt to the active tab.
|
// Columns for the export preview of the active tab (sort arrows omitted —
|
||||||
const tableColumns = useMemo(() => {
|
// the in-tab table owns its own sort state). Same factory the tables use.
|
||||||
const baseValueCols = [
|
const exportTableColumns = useInstancesColumns(activeTableTab);
|
||||||
{
|
|
||||||
title: intl.formatMessage({ id: 'usage.metric.gpuHours' }),
|
|
||||||
dataIndex: 'gpu_hours',
|
|
||||||
key: 'gpu_hours',
|
|
||||||
sorter: true,
|
|
||||||
sortOrder: tableSort.field === 'gpu_hours' ? tableSort.order : null,
|
|
||||||
render: (v: number) => (v ?? 0).toFixed(2)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: intl.formatMessage({ id: 'usage.metric.instanceHours' }),
|
|
||||||
dataIndex: 'instance_hours',
|
|
||||||
key: 'instance_hours',
|
|
||||||
sorter: true,
|
|
||||||
sortOrder:
|
|
||||||
tableSort.field === 'instance_hours' ? tableSort.order : null,
|
|
||||||
render: (v: number) => (v ?? 0).toFixed(2)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
// Instance Types breakdown: just the pretty product name (or flavor slug
|
|
||||||
// for older rows) — no spec sub-line.
|
|
||||||
const instanceTypeColType = {
|
|
||||||
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
|
|
||||||
dataIndex: 'gpu_type',
|
|
||||||
key: 'gpu_type',
|
|
||||||
render: (_v: string, row: ResourceBreakdownItem) => instanceTypeLabel(row)
|
|
||||||
};
|
|
||||||
// Instances breakdown: render through the canonical GPU Instances list
|
|
||||||
// renderer so the label + spec popover are identical. The breakdown row
|
|
||||||
// carries flat MiB fields, so adapt it into the ListItem shape first.
|
|
||||||
const instanceTypeColInstance = {
|
|
||||||
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
|
|
||||||
dataIndex: 'gpu_type',
|
|
||||||
key: 'gpu_type',
|
|
||||||
render: (_v: string, row: ResourceBreakdownItem) =>
|
|
||||||
renderInstanceType(
|
|
||||||
buildInstanceTypeRecordFromMiB({
|
|
||||||
name: row.instance_name,
|
|
||||||
product: row.product || row.gpu_type,
|
|
||||||
gpuCount: row.gpu_count,
|
|
||||||
unitCpuMilli: row.unit_cpu_milli,
|
|
||||||
unitMemoryMib: row.unit_memory_mib,
|
|
||||||
vramMib: row.vram_mib,
|
|
||||||
localStorageMib: row.local_storage_mib,
|
|
||||||
ephemeralMib: row.ephemeral_mib,
|
|
||||||
persistentMib: row.persistent_mib
|
|
||||||
}),
|
|
||||||
{ intl }
|
|
||||||
)
|
|
||||||
};
|
|
||||||
// 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 === 'gpu_type') {
|
|
||||||
return [
|
|
||||||
instanceTypeColType,
|
|
||||||
...baseValueCols,
|
|
||||||
{
|
|
||||||
title: intl.formatMessage({ id: 'usage.metric.activeInstances' }),
|
|
||||||
dataIndex: 'active_instances',
|
|
||||||
key: 'active_instances'
|
|
||||||
},
|
|
||||||
lastActiveCol
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (activeTableTab === 'instance') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: intl.formatMessage({ id: 'usage.table.instance' }),
|
|
||||||
dataIndex: 'instance_name',
|
|
||||||
key: 'instance_name'
|
|
||||||
},
|
|
||||||
instanceTypeColInstance,
|
|
||||||
...baseValueCols,
|
|
||||||
lastActiveCol
|
|
||||||
];
|
|
||||||
}
|
|
||||||
// user tab
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: intl.formatMessage({ id: 'usage.table.user' }),
|
|
||||||
dataIndex: 'user_name',
|
|
||||||
key: 'user_name'
|
|
||||||
},
|
|
||||||
...baseValueCols,
|
|
||||||
lastActiveCol
|
|
||||||
];
|
|
||||||
}, [activeTableTab, tableSort, intl]);
|
|
||||||
|
|
||||||
const tableRows: ResourceBreakdownItem[] = tableData?.items ?? [];
|
|
||||||
|
|
||||||
// Export opens a preview modal (matches the Tokens tab): re-filter + preview
|
// Export opens a preview modal (matches the Tokens tab): re-filter + preview
|
||||||
// the rows, then download. "Chart" = the by-date trend, "Table" = the active
|
// the rows, then download. "Chart" = the by-date trend, "Table" = the active
|
||||||
@@ -433,7 +298,7 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
groupBy: [activeTableTab],
|
groupBy: [activeTableTab],
|
||||||
columns: tableColumns,
|
columns: exportTableColumns,
|
||||||
fileName: `gpu-instances_${activeTableTab}_${dateSuffix}.xlsx`,
|
fileName: `gpu-instances_${activeTableTab}_${dateSuffix}.xlsx`,
|
||||||
sheetName: tabLabel || 'gpu-instances'
|
sheetName: tabLabel || 'gpu-instances'
|
||||||
};
|
};
|
||||||
@@ -445,21 +310,21 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={(dates) => {
|
onChange={(dates) => {
|
||||||
setDateRange(dates);
|
setDateRange(dates);
|
||||||
setTablePage(1);
|
setPageResetKey((k) => k + 1);
|
||||||
}}
|
}}
|
||||||
canManageUsers={canManageUsers}
|
canManageUsers={canManageUsers}
|
||||||
userOptions={userOptions}
|
userOptions={userOptions}
|
||||||
selectedUsers={selectedUsers}
|
selectedUsers={selectedUsers}
|
||||||
onUsersChange={(ids) => {
|
onUsersChange={(ids) => {
|
||||||
setSelectedUsers(ids);
|
setSelectedUsers(ids);
|
||||||
setTablePage(1);
|
setPageResetKey((k) => k + 1);
|
||||||
}}
|
}}
|
||||||
resourceFilter={{
|
resourceFilter={{
|
||||||
options: instanceOptions,
|
options: instanceOptions,
|
||||||
value: selectedInstances,
|
value: selectedInstances,
|
||||||
onChange: (ids) => {
|
onChange: (ids) => {
|
||||||
setSelectedInstances(ids);
|
setSelectedInstances(ids);
|
||||||
setTablePage(1);
|
setPageResetKey((k) => k + 1);
|
||||||
},
|
},
|
||||||
placeholder: intl.formatMessage({ id: 'usage.filter.instance' })
|
placeholder: intl.formatMessage({ id: 'usage.filter.instance' })
|
||||||
}}
|
}}
|
||||||
@@ -496,54 +361,30 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
groupBy={chartGroupBy}
|
groupBy={chartGroupBy}
|
||||||
groupByOptions={chartGroupByOptions}
|
groupByOptions={chartGroupByOptions}
|
||||||
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
|
onGroupByChange={(v) => setChartGroupBy(v as GroupKey | null)}
|
||||||
|
loading={chartLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom tabs + table */}
|
{/* Bottom tabs + table */}
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTableTab}
|
activeKey={activeTableTab}
|
||||||
onChange={(k) => {
|
onChange={(k) => setActiveTableTab(k as GroupKey)}
|
||||||
setActiveTableTab(k as GroupKey);
|
items={TABLE_TABS.map((t) => ({
|
||||||
setTablePage(1);
|
|
||||||
}}
|
|
||||||
items={TABLE_TABS.filter(
|
|
||||||
(t) => t.key !== 'user' || scope === 'all'
|
|
||||||
).map((t) => ({
|
|
||||||
key: t.key,
|
key: t.key,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
|
// Keep every pane mounted so each table holds its own page/sort and
|
||||||
|
// switching tabs neither refetches nor resets the others.
|
||||||
|
forceRender: true,
|
||||||
children: (
|
children: (
|
||||||
<Table
|
<InstancesBreakdownTable
|
||||||
rowKey={(row) =>
|
|
||||||
`${row.gpu_type ?? ''}|${row.instance_id ?? ''}|${row.user_id ?? ''}`
|
|
||||||
}
|
|
||||||
key={t.key}
|
key={t.key}
|
||||||
dataSource={tableRows}
|
groupKey={t.key}
|
||||||
columns={tableColumns as any}
|
dateRange={dateRange}
|
||||||
onChange={(_pagination, _filters, sorter: any) => {
|
scope={scope}
|
||||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
selectedUsers={selectedUsers}
|
||||||
// Sort changed: reset to page 1. Cleared (3rd click) → default
|
selectedInstances={selectedInstances}
|
||||||
// back to GPU Hours descending.
|
pageResetKey={pageResetKey}
|
||||||
const next = s?.order
|
refreshKey={refreshKey}
|
||||||
? {
|
|
||||||
field: (s.columnKey as Metric) ?? 'gpu_hours',
|
|
||||||
order: s.order as 'ascend' | 'descend'
|
|
||||||
}
|
|
||||||
: { field: 'gpu_hours' 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)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}))}
|
}))}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useQueryData } from '@/hooks/use-query-data-list';
|
||||||
|
import {
|
||||||
|
queryGpuInstancesBreakdown,
|
||||||
|
ResourceBreakdownRequest,
|
||||||
|
ResourceBreakdownResponse
|
||||||
|
} from '../../apis/resource';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the `queryGpuInstancesBreakdown` request with shared loading state and
|
||||||
|
* in-flight cancellation (the 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 chart or a table.
|
||||||
|
*/
|
||||||
|
export default function useQueryGpuInstancesBreakdown(option?: {
|
||||||
|
key?: string;
|
||||||
|
}) {
|
||||||
|
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
|
||||||
|
ResourceBreakdownResponse,
|
||||||
|
ResourceBreakdownRequest
|
||||||
|
>({
|
||||||
|
fetchDetail: queryGpuInstancesBreakdown,
|
||||||
|
key: option?.key || 'gpuInstancesBreakdown'
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
detailData,
|
||||||
|
loading,
|
||||||
|
cancelRequest,
|
||||||
|
fetchData
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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 useQueryGpuInstancesBreakdown from '../services/use-query-gpu-instances-breakdown';
|
||||||
|
import useInstancesColumns from './use-instances-columns';
|
||||||
|
|
||||||
|
type Scope = 'self' | 'all';
|
||||||
|
type Metric = 'gpu_hours' | 'instance_hours';
|
||||||
|
type GroupKey = 'gpu_type' | 'instance' | 'user';
|
||||||
|
|
||||||
|
const SORTABLE: Metric[] = ['gpu_hours', 'instance_hours'];
|
||||||
|
|
||||||
|
const PER_PAGE = 50;
|
||||||
|
// sort_by encodes order as a string (`-` prefix = descending), so the fetch
|
||||||
|
// effect dedupes naturally on the primitive. The breakdown order_by takes the
|
||||||
|
// metric key directly. Default: GPU Hours descending.
|
||||||
|
const DEFAULT_SORT = '-gpu_hours';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groupKey: GroupKey;
|
||||||
|
dateRange: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
|
scope: Scope;
|
||||||
|
selectedUsers: number[];
|
||||||
|
selectedInstances: number[];
|
||||||
|
// Bumped by the parent when a filter changes, so each mounted table snaps
|
||||||
|
// back to page 1 independently.
|
||||||
|
pageResetKey?: number;
|
||||||
|
refreshKey?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One GPU-instances breakdown table, owning its own page/sort/data. Each tab
|
||||||
|
* (GPU Type / Instance / User) mounts an instance and keeps it alive (Tabs
|
||||||
|
* `forceRender`), so each keeps its own pagination + sort and switching tabs
|
||||||
|
* neither refetches nor resets the others.
|
||||||
|
*/
|
||||||
|
const InstancesBreakdownTable: React.FC<Props> = ({
|
||||||
|
groupKey,
|
||||||
|
dateRange,
|
||||||
|
scope,
|
||||||
|
selectedUsers,
|
||||||
|
selectedInstances,
|
||||||
|
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 } = useQueryGpuInstancesBreakdown({
|
||||||
|
key: `gpuInstancesBreakdown-${groupKey}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useInstancesColumns(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 = s?.field as Metric;
|
||||||
|
// Cleared (3rd click) or an unsortable column → default GPU Hours desc.
|
||||||
|
const sort_by =
|
||||||
|
!s?.order || !SORTABLE.includes(field)
|
||||||
|
? 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 || selectedInstances.length
|
||||||
|
? {
|
||||||
|
...(selectedUsers.length ? { creator_ids: selectedUsers } : {}),
|
||||||
|
...(selectedInstances.length
|
||||||
|
? { instance_ids: selectedInstances }
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
: 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,
|
||||||
|
selectedInstances,
|
||||||
|
queryParams.page,
|
||||||
|
queryParams.perPage,
|
||||||
|
queryParams.sort_by,
|
||||||
|
refreshKey
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows: ResourceBreakdownItem[] = detailData?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
rowKey={(row) =>
|
||||||
|
`${row.gpu_type ?? ''}|${row.instance_id ?? ''}|${row.user_id ?? ''}`
|
||||||
|
}
|
||||||
|
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 InstancesBreakdownTable;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
buildInstanceTypeRecordFromMiB,
|
||||||
|
renderInstanceType
|
||||||
|
} from '@/pages/gpu-service/instances/utils/render-instance-type';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { ResourceBreakdownItem } from '../../apis/resource';
|
||||||
|
import { instanceTypeLabel } from '../../utils/format-instance-type';
|
||||||
|
import { parseRollup } from '../../utils/time-buckets';
|
||||||
|
|
||||||
|
type GroupKey = 'gpu_type' | 'instance' | 'user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column factory for the GPU-instances breakdown tables, shared by the in-tab
|
||||||
|
* table and the export preview. Columns adapt to the active grouping. Sort
|
||||||
|
* indicators are uncontrolled (antd manages the header arrows); the table
|
||||||
|
* reports changes through its `onChange`.
|
||||||
|
*/
|
||||||
|
const useInstancesColumns = (groupKey: GroupKey) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const baseValueCols = [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'usage.metric.gpuHours' }),
|
||||||
|
dataIndex: 'gpu_hours',
|
||||||
|
key: 'gpu_hours',
|
||||||
|
sorter: true,
|
||||||
|
render: (v: number) => (v ?? 0).toFixed(2)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'usage.metric.instanceHours' }),
|
||||||
|
dataIndex: 'instance_hours',
|
||||||
|
key: 'instance_hours',
|
||||||
|
sorter: true,
|
||||||
|
render: (v: number) => (v ?? 0).toFixed(2)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
// Instance Types breakdown: just the pretty product name (or flavor slug
|
||||||
|
// for older rows) — no spec sub-line.
|
||||||
|
const instanceTypeColType = {
|
||||||
|
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
|
||||||
|
dataIndex: 'gpu_type',
|
||||||
|
key: 'gpu_type',
|
||||||
|
render: (_v: string, row: ResourceBreakdownItem) => instanceTypeLabel(row)
|
||||||
|
};
|
||||||
|
// Instances breakdown: render through the canonical GPU Instances list
|
||||||
|
// renderer so the label + spec popover are identical. The breakdown row
|
||||||
|
// carries flat MiB fields, so adapt it into the ListItem shape first.
|
||||||
|
const instanceTypeColInstance = {
|
||||||
|
title: intl.formatMessage({ id: 'usage.table.instanceType' }),
|
||||||
|
dataIndex: 'gpu_type',
|
||||||
|
key: 'gpu_type',
|
||||||
|
render: (_v: string, row: ResourceBreakdownItem) =>
|
||||||
|
renderInstanceType(
|
||||||
|
buildInstanceTypeRecordFromMiB({
|
||||||
|
name: row.instance_name,
|
||||||
|
product: row.product || row.gpu_type,
|
||||||
|
gpuCount: row.gpu_count,
|
||||||
|
unitCpuMilli: row.unit_cpu_milli,
|
||||||
|
unitMemoryMib: row.unit_memory_mib,
|
||||||
|
vramMib: row.vram_mib,
|
||||||
|
localStorageMib: row.local_storage_mib,
|
||||||
|
ephemeralMib: row.ephemeral_mib,
|
||||||
|
persistentMib: row.persistent_mib
|
||||||
|
}),
|
||||||
|
{ intl }
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// 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 === 'gpu_type') {
|
||||||
|
return [
|
||||||
|
instanceTypeColType,
|
||||||
|
...baseValueCols,
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'usage.metric.activeInstances' }),
|
||||||
|
dataIndex: 'active_instances',
|
||||||
|
key: 'active_instances'
|
||||||
|
},
|
||||||
|
lastActiveCol
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (groupKey === 'instance') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'usage.table.instance' }),
|
||||||
|
dataIndex: 'instance_name',
|
||||||
|
key: 'instance_name'
|
||||||
|
},
|
||||||
|
instanceTypeColInstance,
|
||||||
|
...baseValueCols,
|
||||||
|
lastActiveCol
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// user tab
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'usage.table.user' }),
|
||||||
|
dataIndex: 'user_name',
|
||||||
|
key: 'user_name'
|
||||||
|
},
|
||||||
|
...baseValueCols,
|
||||||
|
lastActiveCol
|
||||||
|
];
|
||||||
|
}, [groupKey, intl]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useInstancesColumns;
|
||||||
@@ -65,23 +65,19 @@ const StorageTab: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(() => {
|
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(() => {
|
||||||
return access.canSeeOrgAdmin
|
const tabs = [
|
||||||
? [
|
{
|
||||||
{
|
key: 'volume' as GroupKey,
|
||||||
key: 'volume',
|
label: intl.formatMessage({ id: 'usage.tabs.storage' })
|
||||||
label: intl.formatMessage({ id: 'usage.tabs.storage' })
|
}
|
||||||
},
|
];
|
||||||
{
|
if (access.canSeeOrgAdmin) {
|
||||||
key: 'user',
|
tabs.push({
|
||||||
label: intl.formatMessage({ id: 'usage.table.users' })
|
key: 'user' as GroupKey,
|
||||||
}
|
label: intl.formatMessage({ id: 'usage.table.users' })
|
||||||
]
|
});
|
||||||
: [
|
}
|
||||||
{
|
return tabs;
|
||||||
key: 'volume',
|
|
||||||
label: intl.formatMessage({ id: 'usage.tabs.storage' })
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}, [intl, access.canSeeOrgAdmin]);
|
}, [intl, access.canSeeOrgAdmin]);
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const APIKeys: React.FC<{
|
|||||||
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
||||||
setQueryParams((prev) => ({
|
setQueryParams((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
page: 1,
|
||||||
sort_by
|
sort_by
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const Models: React.FC<{
|
|||||||
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
||||||
setQueryParams((prev) => ({
|
setQueryParams((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
page: 1,
|
||||||
sort_by
|
sort_by
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const Users: React.FC<{
|
|||||||
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
sorter.order === 'descend' ? `-${sorter.field}` : sorter.field;
|
||||||
setQueryParams((prev) => ({
|
setQueryParams((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
page: 1,
|
||||||
sort_by
|
sort_by
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user