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
|
// fields keep the clean (stale) name; the tables show a DeletedTag off this
|
||||||
// flag plus the id, matching the Tokens tab.
|
// flag plus the id, matching the Tokens tab.
|
||||||
deleted?: boolean;
|
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 / …)
|
// Grouped-trend rows carry the sub-group label (sku / instance / user / …)
|
||||||
// alongside ``date`` so the chart can pivot one series per group.
|
// alongside ``date`` so the chart can pivot one series per group.
|
||||||
group?: string;
|
group?: string;
|
||||||
@@ -220,6 +225,11 @@ interface ServerBreakdownItem {
|
|||||||
persistent_mib?: number | null;
|
persistent_mib?: number | null;
|
||||||
storage_type?: string | null;
|
storage_type?: string | null;
|
||||||
capacity_mib?: number | 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;
|
} | null;
|
||||||
metrics: ServerMetrics;
|
metrics: ServerMetrics;
|
||||||
}
|
}
|
||||||
@@ -346,6 +356,12 @@ function flattenItem(
|
|||||||
if (dims.persistent_mib != null) flat.persistent_mib = dims.persistent_mib;
|
if (dims.persistent_mib != null) flat.persistent_mib = dims.persistent_mib;
|
||||||
if (dims.storage_type) flat.storage_type = dims.storage_type;
|
if (dims.storage_type) flat.storage_type = dims.storage_type;
|
||||||
if (dims.capacity_mib != null) flat.capacity_mib = dims.capacity_mib;
|
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
|
// Instance-type grouped trend: the series label (``group``) defaults to the
|
||||||
// raw flavor slug. Instance Types are grouped by actual shape, so label each
|
// raw flavor slug. Instance Types are grouped by actual shape, so label each
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ResourceBreakdownRequest,
|
ResourceBreakdownRequest,
|
||||||
ResourceBreakdownResponse
|
ResourceBreakdownResponse
|
||||||
} from '../apis/resource';
|
} from '../apis/resource';
|
||||||
|
import { withDeletedMark } from '../utils/deleted-label';
|
||||||
import {
|
import {
|
||||||
exportBreakdownRows,
|
exportBreakdownRows,
|
||||||
toExportColumns
|
toExportColumns
|
||||||
@@ -61,6 +62,15 @@ interface ResourceExportDataProps {
|
|||||||
initialDateRange: [dayjs.Dayjs, dayjs.Dayjs];
|
initialDateRange: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
initialSelectedUsers: number[];
|
initialSelectedUsers: number[];
|
||||||
initialSelectedResources: 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 };
|
const INITIAL_PAGE = { page: 1, perPage: 100 };
|
||||||
@@ -81,7 +91,8 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
resourceFilter,
|
resourceFilter,
|
||||||
initialDateRange,
|
initialDateRange,
|
||||||
initialSelectedUsers,
|
initialSelectedUsers,
|
||||||
initialSelectedResources
|
initialSelectedResources,
|
||||||
|
deletedNameFields
|
||||||
} = props;
|
} = props;
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
@@ -157,7 +168,36 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
[columns, pageParams.page, pageParams.perPage, intl]
|
[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) => {
|
const handlePageChange = (page: number, perPage: number) => {
|
||||||
setPageParams({ page, perPage });
|
setPageParams({ page, perPage });
|
||||||
@@ -170,7 +210,7 @@ const ResourceExportData: React.FC<ResourceExportDataProps> = (props) => {
|
|||||||
try {
|
try {
|
||||||
const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage));
|
const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage));
|
||||||
exportBreakdownRows(
|
exportBreakdownRows(
|
||||||
res.items ?? [],
|
markRows(res.items ?? []),
|
||||||
toExportColumns(columns),
|
toExportColumns(columns),
|
||||||
fileName,
|
fileName,
|
||||||
sheetName
|
sheetName
|
||||||
|
|||||||
@@ -64,13 +64,13 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
|
|
||||||
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(() => {
|
const TABLE_TABS: { key: GroupKey; label: string }[] = useMemo(() => {
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
|
||||||
key: 'gpu_type' as GroupKey,
|
|
||||||
label: intl.formatMessage({ id: 'usage.table.instanceTypes' })
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'instance' as GroupKey,
|
key: 'instance' as GroupKey,
|
||||||
label: intl.formatMessage({ id: 'usage.table.instances' })
|
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.
|
// 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);
|
const [chartGroupBy, setChartGroupBy] = useState<GroupKey | null>(null);
|
||||||
// ``null`` group_by = no row grouping, just the summary KPIs.
|
// ``null`` group_by = no row grouping, just the summary KPIs.
|
||||||
// The chart needs the ``date`` group; tables use the active table tab.
|
// 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 } =
|
const { creators: userOptions, instances: instanceOptions } =
|
||||||
useResourceMeta(scope);
|
useResourceMeta(scope);
|
||||||
@@ -323,6 +323,21 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
dataIndex: 'date',
|
dataIndex: 'date',
|
||||||
key: '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' }),
|
title: intl.formatMessage({ id: 'usage.metric.gpuHours' }),
|
||||||
dataIndex: 'gpu_hours',
|
dataIndex: 'gpu_hours',
|
||||||
@@ -349,7 +364,7 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
|
|
||||||
// The preview modal now only backs the by-date chart export.
|
// The preview modal now only backs the by-date chart export.
|
||||||
const exportConfig = {
|
const exportConfig = {
|
||||||
groupBy: ['date'],
|
groupBy: ['date', 'instance'],
|
||||||
columns: chartExportColumns,
|
columns: chartExportColumns,
|
||||||
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
|
fileName: `gpu-instances_chart_${dateSuffix}.xlsx`,
|
||||||
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
|
sheetName: intl.formatMessage({ id: 'usage.tabs.gpuInstances' })
|
||||||
@@ -462,6 +477,20 @@ const GpuInstancesTab: React.FC = () => {
|
|||||||
initialDateRange={dateRange}
|
initialDateRange={dateRange}
|
||||||
initialSelectedUsers={selectedUsers}
|
initialSelectedUsers={selectedUsers}
|
||||||
initialSelectedResources={selectedInstances}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -307,6 +307,21 @@ const StorageTab: React.FC = () => {
|
|||||||
dataIndex: 'date',
|
dataIndex: 'date',
|
||||||
key: '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' }),
|
title: intl.formatMessage({ id: 'usage.metric.gbDays' }),
|
||||||
dataIndex: 'storage_gb_days',
|
dataIndex: 'storage_gb_days',
|
||||||
@@ -333,7 +348,7 @@ const StorageTab: React.FC = () => {
|
|||||||
|
|
||||||
// The preview modal now only backs the by-date chart export.
|
// The preview modal now only backs the by-date chart export.
|
||||||
const exportConfig = {
|
const exportConfig = {
|
||||||
groupBy: ['date'],
|
groupBy: ['date', 'volume'],
|
||||||
columns: chartExportColumns,
|
columns: chartExportColumns,
|
||||||
fileName: `storage_chart_${dateSuffix}.xlsx`,
|
fileName: `storage_chart_${dateSuffix}.xlsx`,
|
||||||
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
|
sheetName: intl.formatMessage({ id: 'usage.tabs.storage' })
|
||||||
@@ -442,6 +457,20 @@ const StorageTab: React.FC = () => {
|
|||||||
initialDateRange={dateRange}
|
initialDateRange={dateRange}
|
||||||
initialSelectedUsers={selectedUsers}
|
initialSelectedUsers={selectedUsers}
|
||||||
initialSelectedResources={selectedVolumes}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user