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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user