feat(usage): break down chart export by instance/volume with owner user column

Group the GPU Instances and Storage chart exports by date+instance /
date+volume, add Instance/Storage and (org-admin only) User columns, and
mark deleted instances/volumes and their owner user independently in both
the preview and the exported sheet.
This commit is contained in:
jialin
2026-07-10 16:31:35 +08:00
committed by jialin
parent f92499cbe5
commit 1f861c5844
4 changed files with 124 additions and 10 deletions
+16
View File
@@ -69,6 +69,11 @@ export interface ResourceBreakdownItem extends ResourceBreakdownSummary {
// fields keep the clean (stale) name; the tables show a DeletedTag off this
// flag plus the id, matching the Tokens tab.
deleted?: boolean;
// Owner user of a per-instance / per-volume row (compound date+dim grouping),
// with its own deletion state — independent of the row's ``deleted`` (which
// refers to the grouped instance/volume). Lets the export mark the User
// column separately, matching the Tokens tab.
user_deleted?: boolean;
// Grouped-trend rows carry the sub-group label (sku / instance / user / …)
// alongside ``date`` so the chart can pivot one series per group.
group?: string;
@@ -220,6 +225,11 @@ interface ServerBreakdownItem {
persistent_mib?: number | null;
storage_type?: string | null;
capacity_mib?: number | null;
// Owner user of the instance/volume row (compound date+dim grouping),
// carried alongside the grouped entity so the export can show a User column.
user_id?: number | null;
user_name?: string | null;
user_deleted?: boolean | null;
} | null;
metrics: ServerMetrics;
}
@@ -346,6 +356,12 @@ function flattenItem(
if (dims.persistent_mib != null) flat.persistent_mib = dims.persistent_mib;
if (dims.storage_type) flat.storage_type = dims.storage_type;
if (dims.capacity_mib != null) flat.capacity_mib = dims.capacity_mib;
// Owner user of a per-instance / per-volume row — the grouped entity is the
// instance/volume (``key``/``deleted``), so the owner rides in dimensions
// with its own deleted flag for the export's User column.
if (dims.user_name != null) flat.user_name = dims.user_name;
if (dims.user_id != null) flat.user_id = dims.user_id;
if (dims.user_deleted != null) flat.user_deleted = !!dims.user_deleted;
}
// Instance-type grouped trend: the series label (``group``) defaults to the
// raw flavor slug. Instance Types are grouped by actual shape, so label each
@@ -21,6 +21,7 @@ import {
ResourceBreakdownRequest,
ResourceBreakdownResponse
} from '../apis/resource';
import { withDeletedMark } from '../utils/deleted-label';
import {
exportBreakdownRows,
toExportColumns
@@ -61,6 +62,15 @@ interface ResourceExportDataProps {
initialDateRange: [dayjs.Dayjs, dayjs.Dayjs];
initialSelectedUsers: number[];
initialSelectedResources: number[];
// Name columns that carry a "[Deleted.#id]" marker when their entity is gone.
// Each maps a clean-name field to its id + own deleted flag, so a compound
// (date + instance/volume) row can mark the instance/volume and its owner
// User independently — mirroring the Tokens tab's chart export.
deletedNameFields?: {
name: string;
id: string;
deletedFlag: string;
}[];
}
const INITIAL_PAGE = { page: 1, perPage: 100 };
@@ -81,7 +91,8 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
resourceFilter,
initialDateRange,
initialSelectedUsers,
initialSelectedResources
initialSelectedResources,
deletedNameFields
} = props;
const intl = useIntl();
@@ -157,7 +168,36 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
[columns, pageParams.page, pageParams.perPage, intl]
);
const rows: ResourceBreakdownItem[] = data?.items ?? [];
// Append the "[Deleted.#id]" text marker to each configured name field of
// deleted rows — used for both the preview cells and the exported sheet, so
// the two always agree. Rows are export/preview-only copies. Each field marks
// off its own deleted flag (instance/volume vs. its owner User) so a compound
// row can flag the two entities independently.
const markRows = (
items: ResourceBreakdownItem[]
): ResourceBreakdownItem[] => {
if (!deletedNameFields?.length) return items;
const deletedWord = intl.formatMessage({ id: 'usage.table.deleted' });
return items.map((item) => {
let next = item;
deletedNameFields.forEach((f) => {
if ((item as any)[f.deletedFlag]) {
next = {
...next,
[f.name]: withDeletedMark(
(item as any)[f.name] ?? '',
true,
deletedWord,
(item as any)[f.id]
)
};
}
});
return next;
});
};
const rows: ResourceBreakdownItem[] = markRows(data?.items ?? []);
const handlePageChange = (page: number, perPage: number) => {
setPageParams({ page, perPage });
@@ -170,7 +210,7 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
try {
const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage));
exportBreakdownRows(
res.items ?? [],
markRows(res.items ?? []),
toExportColumns(columns),
fileName,
sheetName
+35 -6
View File
@@ -64,13 +64,13 @@ const GpuInstancesTab: React.FC = () => {
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(() => {
const tabs = [
{
key: 'gpu_type' as GroupKey,
label: intl.formatMessage({ id: 'usage.table.instanceTypes' })
},
{
key: 'instance' as GroupKey,
label: intl.formatMessage({ id: 'usage.table.instances' })
},
{
key: 'gpu_type' as GroupKey,
label: intl.formatMessage({ id: 'usage.table.instanceTypes' })
}
];
// Managers see the org-wide User breakdown; members only their own rows.
@@ -106,7 +106,7 @@ const GpuInstancesTab: React.FC = () => {
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
// ``null`` group_by = no row grouping, just the summary KPIs.
// The chart needs the ``date`` group; tables use the active table tab.
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('gpu_type');
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('instance');
const { creators: userOptions, instances: instanceOptions } =
useResourceMeta(scope);
@@ -323,6 +323,21 @@ const GpuInstancesTab: React.FC = () => {
dataIndex: 'date',
key: 'date'
},
{
title: intl.formatMessage({ id: 'usage.table.instance' }),
dataIndex: 'instance_name',
key: 'instance_name'
},
// Owner User column — org admins only (members see just their own rows).
...(canManageUsers
? [
{
title: intl.formatMessage({ id: 'usage.table.users' }),
dataIndex: 'user_name',
key: 'user_name'
}
]
: []),
{
title: intl.formatMessage({ id: 'usage.metric.gpuHours' }),
dataIndex: 'gpu_hours',
@@ -349,7 +364,7 @@ const GpuInstancesTab: React.FC = () => {
// The preview modal now only backs the by-date chart export.
const exportConfig = {
groupBy: ['date'],
groupBy: ['date', 'instance'],
columns: chartExportColumns,
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
@@ -462,6 +477,20 @@ const GpuInstancesTab: React.FC = () => {
initialDateRange={dateRange}
initialSelectedUsers={selectedUsers}
initialSelectedResources={selectedInstances}
deletedNameFields={[
// The row's ``deleted`` is the grouped instance; the owner user
// carries its own ``user_deleted``.
{ name: 'instance_name', id: 'instance_id', deletedFlag: 'deleted' },
...(canManageUsers
? [
{
name: 'user_name',
id: 'user_id',
deletedFlag: 'user_deleted'
}
]
: [])
]}
/>
</div>
);
+30 -1
View File
@@ -307,6 +307,21 @@ const StorageTab: React.FC = () => {
dataIndex: 'date',
key: 'date'
},
{
title: intl.formatMessage({ id: 'usage.tabs.storage' }),
dataIndex: 'volume_name',
key: 'volume_name'
},
// Owner User column — org admins only (members see just their own rows).
...(canManageUsers
? [
{
title: intl.formatMessage({ id: 'usage.table.users' }),
dataIndex: 'user_name',
key: 'user_name'
}
]
: []),
{
title: intl.formatMessage({ id: 'usage.metric.gbDays' }),
dataIndex: 'storage_gb_days',
@@ -333,7 +348,7 @@ const StorageTab: React.FC = () => {
// The preview modal now only backs the by-date chart export.
const exportConfig = {
groupBy: ['date'],
groupBy: ['date', 'volume'],
columns: chartExportColumns,
fileName: `storage_chart_${dateSuffix}.xlsx`,
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
@@ -442,6 +457,20 @@ const StorageTab: React.FC = () => {
initialDateRange={dateRange}
initialSelectedUsers={selectedUsers}
initialSelectedResources={selectedVolumes}
deletedNameFields={[
// The row's ``deleted`` is the grouped volume; the owner user
// carries its own ``user_deleted``.
{ name: 'volume_name', id: 'volume_id', deletedFlag: 'deleted' },
...(canManageUsers
? [
{
name: 'user_name',
id: 'user_id',
deletedFlag: 'user_deleted'
}
]
: [])
]}
/>
</div>
);