diff --git a/src/pages/usage/apis/resource.ts b/src/pages/usage/apis/resource.ts index ebbc8d42..5802c38f 100644 --- a/src/pages/usage/apis/resource.ts +++ b/src/pages/usage/apis/resource.ts @@ -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 diff --git a/src/pages/usage/components/resource-export-data.tsx b/src/pages/usage/components/resource-export-data.tsx index f71d33fa..340e51ac 100644 --- a/src/pages/usage/components/resource-export-data.tsx +++ b/src/pages/usage/components/resource-export-data.tsx @@ -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 = (props) => { resourceFilter, initialDateRange, initialSelectedUsers, - initialSelectedResources + initialSelectedResources, + deletedNameFields } = props; const intl = useIntl(); @@ -157,7 +168,36 @@ const ResourceExportData: React.FC = (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 = (props) => { try { const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage)); exportBreakdownRows( - res.items ?? [], + markRows(res.items ?? []), toExportColumns(columns), fileName, sheetName diff --git a/src/pages/usage/instances-tab/index.tsx b/src/pages/usage/instances-tab/index.tsx index 89df42bb..563d5956 100644 --- a/src/pages/usage/instances-tab/index.tsx +++ b/src/pages/usage/instances-tab/index.tsx @@ -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(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('gpu_type'); + const [activeTableTab, setActiveTableTab] = useState('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' + } + ] + : []) + ]} /> ); diff --git a/src/pages/usage/storage-tab/index.tsx b/src/pages/usage/storage-tab/index.tsx index 2f8fb389..945cff06 100644 --- a/src/pages/usage/storage-tab/index.tsx +++ b/src/pages/usage/storage-tab/index.tsx @@ -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' + } + ] + : []) + ]} /> );