/** * Export-preview modal for the resource tabs (GPU Instances / Storage) — * the counterpart to the Tokens tab's ``ExportData``. * * Opening it shows a sticky filter bar (re-filter date / user / resource * without leaving the dialog) above a paginated preview of exactly the rows * that will be written, then an Export footer button downloads the full * filtered result set (not just the visible page) to Excel. * * It's generic over the breakdown endpoint, ``group_by`` and column set, so * the same component backs both the "Export Chart Data" (by-date trend) and * "Export Table Data" (active group-by) entries on either tab. */ import { ModalFooter, ScrollerModal } from '@gpustack/core-ui'; import { useIntl } from '@umijs/max'; import { Table } from 'antd'; import dayjs from 'dayjs'; import React, { useEffect, useMemo, useState } from 'react'; import { ResourceBreakdownItem, ResourceBreakdownRequest, ResourceBreakdownResponse } from '../apis/resource'; import { withDeletedMark } from '../utils/deleted-label'; import { exportBreakdownRows, toExportColumns } from '../utils/export-breakdown'; import ResourceFilterBar from './resource-filter-bar'; type Scope = 'self' | 'all'; interface SelectOption { value: number; label: string; deleted?: boolean; // ``org`` / ``user`` / ``group`` — set on organization options for the tag. kind?: string; } interface ResourceExportDataProps { open: boolean; onCancel: () => void; title: string; // Breakdown endpoint + the group_by / columns this export covers. queryFn: ( req: ResourceBreakdownRequest ) => Promise; groupBy: NonNullable; // antd column specs — drive both the preview table and (via dataIndex/title) // the exported sheet. columns: any[]; fileName: string; sheetName?: string; // Filter-bar wiring, seeded from the tab's current filters. scope: Scope; canManageUsers: boolean; userOptions: SelectOption[]; resourceFilter: { options: SelectOption[]; placeholder: string; // Which filters key the selected ids map to on the request. key: 'instance_ids' | 'volume_ids'; }; initialDateRange: [dayjs.Dayjs, dayjs.Dayjs]; initialSelectedUsers: number[]; initialSelectedResources: number[]; // Platform-wide "All" view only; empty otherwise. Mirrors the tab's // Organization / User Group filters so the export can re-narrow by them. organizationOptions?: SelectOption[]; userGroupOptions?: SelectOption[]; initialSelectedOrganizations?: number[]; initialSelectedUserGroups?: 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 ResourceExportData: React.FC = (props) => { const { open, onCancel, title, queryFn, groupBy, columns, fileName, sheetName = 'usage', scope, canManageUsers, userOptions, resourceFilter, initialDateRange, initialSelectedUsers, initialSelectedResources, organizationOptions = [], userGroupOptions = [], initialSelectedOrganizations = [], initialSelectedUserGroups = [], deletedNameFields } = props; const intl = useIntl(); const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>(initialDateRange); const [selectedUsers, setSelectedUsers] = useState(initialSelectedUsers); const [selectedResources, setSelectedResources] = useState( initialSelectedResources ); const [selectedOrganizations, setSelectedOrganizations] = useState( initialSelectedOrganizations ); const [selectedUserGroups, setSelectedUserGroups] = useState( initialSelectedUserGroups ); const [pageParams, setPageParams] = useState(INITIAL_PAGE); const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [exporting, setExporting] = useState(false); const buildRequest = ( page: number, perPage: number ): ResourceBreakdownRequest => ({ start_date: dateRange[0].format('YYYY-MM-DD'), end_date: dateRange[1].format('YYYY-MM-DD'), scope, group_by: groupBy, granularity: 'day', filters: selectedUsers.length || selectedResources.length || selectedOrganizations.length || selectedUserGroups.length ? { ...(selectedUsers.length ? { creator_ids: selectedUsers } : {}), ...(selectedResources.length ? { [resourceFilter.key]: selectedResources } : {}), ...(selectedOrganizations.length ? { organization_ids: selectedOrganizations } : {}), ...(selectedUserGroups.length ? { user_group_ids: selectedUserGroups } : {}) } : undefined, page, perPage }); const fetchPreview = async (page: number, perPage: number) => { setLoading(true); try { const res = await queryFn(buildRequest(page, perPage)); setData(res); } finally { setLoading(false); } }; // Reset to the tab's filters every time the dialog opens, then fetch. useEffect(() => { if (!open) return; setDateRange(initialDateRange); setSelectedUsers(initialSelectedUsers); setSelectedResources(initialSelectedResources); setSelectedOrganizations(initialSelectedOrganizations); setSelectedUserGroups(initialSelectedUserGroups); setPageParams(INITIAL_PAGE); }, [open]); // Refetch the preview whenever the in-dialog filters or page change. useEffect(() => { if (!open) return; fetchPreview(pageParams.page, pageParams.perPage); }, [ open, dateRange, selectedUsers, selectedResources, selectedOrganizations, selectedUserGroups, pageParams ]); const previewColumns = useMemo( () => [ { title: intl.formatMessage({ id: 'resources.table.index' }), width: 60, render: (_t: any, _r: any, index: number) => (pageParams.page - 1) * pageParams.perPage + index + 1 }, ...columns ], [columns, pageParams.page, pageParams.perPage, intl] ); // 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; }); }; // Normalize the date bucket to a plain calendar day (drop the ``T00:00:00`` // the hourly ``metered_usage`` carries) so the export matches the Tokens // tab's date-only format. The export always requests day granularity. const formatRowDates = ( items: ResourceBreakdownItem[] ): ResourceBreakdownItem[] => items.map((i) => i.date ? { ...i, date: dayjs(i.date).format('YYYY-MM-DD') } : i ); const rows: ResourceBreakdownItem[] = formatRowDates( markRows(data?.items ?? []) ); const handlePageChange = (page: number, perPage: number) => { setPageParams({ page, perPage }); }; // Export the full filtered set, not just the visible page. ``page: -1`` is // the backend's no-pagination sentinel (perPage is then ignored). const handleSubmit = async () => { setExporting(true); try { const res = await queryFn(buildRequest(-1, INITIAL_PAGE.perPage)); exportBreakdownRows( formatRowDates(markRows(res.items ?? [])), toExportColumns(columns), fileName, sheetName ); } finally { setExporting(false); } }; return ( } >
{ setDateRange(dates); setPageParams(INITIAL_PAGE); }} canManageUsers={canManageUsers} userOptions={userOptions} selectedUsers={selectedUsers} onUsersChange={(ids) => { setSelectedUsers(ids); setPageParams(INITIAL_PAGE); }} resourceFilter={{ options: resourceFilter.options, value: selectedResources, onChange: (ids) => { setSelectedResources(ids); setPageParams(INITIAL_PAGE); }, placeholder: resourceFilter.placeholder }} organizationOptions={organizationOptions} userGroupOptions={userGroupOptions} selectedOrganizations={selectedOrganizations} selectedUserGroups={selectedUserGroups} onOrganizationsChange={(ids) => { setSelectedOrganizations(ids); setPageParams(INITIAL_PAGE); }} onUserGroupsChange={(ids) => { setSelectedUserGroups(ids); setPageParams(INITIAL_PAGE); }} />
`${index}`} loading={{ spinning: loading, size: 'middle' }} virtual scroll={{ y: 400 }} pagination={{ size: 'small', pageSize: pageParams.perPage, current: pageParams.page, total: data?.pagination.total || 0, onChange: handlePageChange, hideOnSinglePage: pageParams.perPage === 100, showSizeChanger: true }} >
); }; export default ResourceExportData;