diff --git a/src/pages/usage/components/gpu-instances-tab.tsx b/src/pages/usage/components/gpu-instances-tab.tsx new file mode 100644 index 00000000..34388d76 --- /dev/null +++ b/src/pages/usage/components/gpu-instances-tab.tsx @@ -0,0 +1,420 @@ +/** + * GPU Instances Tab — per-instance compute usage view. + * + * Layout mirrors the existing Token tab: + * 1. Top filter bar (date range + scope) + * 2. KPI row (GPU-Hours / GPU-Minutes / Instances / GPU Types / Active Users) + * 3. Daily bar chart with metric + group_by switches + * 4. Bottom tab table grouped by GPU Type / Instance / User + * + * Talks to the new ``/usage/gpu-instances/{meta,breakdown}`` endpoints. + */ +import useCoolColors from '@/hooks/use-cool-colors'; +import { formatLargeNumber } from '@/utils'; +import { SimpleCard } from '@gpustack/core-ui'; +import { useAccess } from '@umijs/max'; +import { Table, Tabs } from 'antd'; +import dayjs from 'dayjs'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + queryGpuInstancesBreakdown, + ResourceBreakdownItem, + ResourceBreakdownRequest, + ResourceBreakdownResponse +} from '../apis/resource'; +import useResourceMeta from '../hooks/use-resource-meta'; +import { + bucketKey, + generateBucketRange, + Granularity +} from '../utils/time-buckets'; +import MetricChartCard from './metric-chart-card'; +import ResourceExportData from './resource-export-data'; +import ResourceFilterBar from './resource-filter-bar'; + +type Scope = 'self' | 'all'; +type Metric = 'gpu_hours' | 'instance_hours'; +type GroupKey = 'gpu_type' | 'instance' | 'user'; + +const METRIC_OPTIONS: { value: Metric; label: string }[] = [ + { value: 'gpu_hours', label: 'GPU Hours' }, + { value: 'instance_hours', label: 'Instance Hours' } +]; + +const TABLE_TABS: { key: GroupKey; label: string }[] = [ + { key: 'gpu_type', label: 'Instance Types' }, + { key: 'instance', label: 'Instances' }, + { key: 'user', label: 'Users' } +]; + +const GpuInstancesTab: React.FC = () => { + const access = useAccess(); + // ``useCoolColors`` returns a memoized factory; resolve it once into a + // fixed 5-slot palette here so the rest of the component reads as + // array access. + const coolColors = useCoolColors()(5); + + // No All/My dropdown (matches the Tokens tab): managers see the org-wide + // view and narrow it with the user filter, others only their own rows. + const canManageUsers = !!access.canSeeOrgAdmin; + const scope: Scope = canManageUsers ? 'all' : 'self'; + + const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([ + dayjs().subtract(29, 'day'), + dayjs() + ]); + const [selectedUsers, setSelectedUsers] = useState([]); + const [selectedInstances, setSelectedInstances] = useState([]); + const [refreshKey, setRefreshKey] = useState(0); + const [metric, setMetric] = useState('gpu_hours'); + const [granularity, setGranularity] = useState('day'); + // ``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 { creators: userOptions, instances: instanceOptions } = + useResourceMeta(scope); + + // Two independent fetches: one for the daily chart (group_by=date), + // one for the table (group_by=tab key). Both reuse the same date / + // scope filters so the views stay in sync. + const [chartData, setChartData] = useState( + null + ); + const [tableData, setTableData] = useState( + null + ); + const [tablePage, setTablePage] = useState(1); + + const baseRequest = (): Omit => ({ + start_date: dateRange[0].format('YYYY-MM-DD'), + end_date: dateRange[1].format('YYYY-MM-DD'), + scope, + granularity, + filters: + selectedUsers.length || selectedInstances.length + ? { + ...(selectedUsers.length ? { creator_ids: selectedUsers } : {}), + ...(selectedInstances.length + ? { instance_ids: selectedInstances } + : {}) + } + : undefined, + page: 1, + perPage: 50 + }); + + const fetchChart = async () => { + try { + const data = await queryGpuInstancesBreakdown({ + ...baseRequest(), + group_by: 'date' + }); + setChartData(data); + } catch { + // Network/auth errors surface via the global request interceptor; + // keep the previous chart so the UI doesn't flash empty. + } + }; + + const fetchTable = async () => { + try { + const data = await queryGpuInstancesBreakdown({ + ...baseRequest(), + group_by: activeTableTab, + page: tablePage + }); + setTableData(data); + } catch { + // Same rationale as fetchChart. + } + }; + + useEffect(() => { + fetchChart(); + }, [dateRange, selectedUsers, selectedInstances, granularity, refreshKey]); + + useEffect(() => { + fetchTable(); + }, [ + dateRange, + selectedUsers, + selectedInstances, + activeTableTab, + tablePage, + refreshKey + ]); + + // KPI summary cards — pull from the chart summary since both queries + // return the same scope-wide totals. + const summary = chartData?.summary; + const summaryCards = useMemo( + () => [ + { + label: formatLargeNumber( + Math.round((summary?.gpu_hours ?? 0) * 10) / 10 + ) as string, + value: 'GPU Hours', + color: coolColors[0] + }, + { + label: formatLargeNumber( + Math.round((summary?.instance_hours ?? 0) * 10) / 10 + ) as string, + value: 'Instance Hours', + color: coolColors[1] + }, + { + label: (summary?.active_instances ?? 0).toString(), + value: 'Active Instances', + color: coolColors[2] + }, + { + label: (summary?.active_users ?? 0).toString(), + value: 'Active Users', + color: coolColors[3] + } + ], + [summary, coolColors] + ); + + // Build chart series — single series of the selected metric, plotted + // along the contiguous date range. + const dataByDate = useMemo(() => { + const map = new Map(); + chartData?.items.forEach((item) => { + if (!item.date) return; + map.set(bucketKey(item.date, granularity), Number(item[metric] ?? 0)); + }); + return map; + }, [chartData, metric, granularity]); + const xAxis = useMemo(() => { + const keys = new Set( + generateBucketRange( + dateRange[0].format('YYYY-MM-DD'), + dateRange[1].format('YYYY-MM-DD'), + granularity + ) + ); + dataByDate.forEach((_v, k) => keys.add(k)); + return Array.from(keys).sort(); + }, [dataByDate, dateRange, granularity]); + + const seriesData = [ + { + name: METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric, + data: xAxis.map((d) => dataByDate.get(d) ?? 0), + color: coolColors[0] + } + ]; + + // Table columns adapt to the active tab. + const tableColumns = useMemo(() => { + const baseValueCols = [ + { + title: 'GPU Hours', + dataIndex: 'gpu_hours', + key: 'gpu_hours', + render: (v: number) => (v ?? 0).toFixed(2) + }, + { + title: 'Instance Hours', + dataIndex: 'instance_hours', + key: 'instance_hours', + render: (v: number) => (v ?? 0).toFixed(2) + } + ]; + if (activeTableTab === 'gpu_type') { + return [ + { title: 'Instance Type', dataIndex: 'gpu_type', key: 'gpu_type' }, + ...baseValueCols, + { + title: 'Active Instances', + dataIndex: 'active_instances', + key: 'active_instances' + }, + { title: 'Last Active', dataIndex: 'last_active', key: 'last_active' } + ]; + } + if (activeTableTab === 'instance') { + return [ + { title: 'Instance', dataIndex: 'instance_name', key: 'instance_name' }, + { title: 'Instance Type', dataIndex: 'gpu_type', key: 'gpu_type' }, + ...baseValueCols, + { title: 'Last Active', dataIndex: 'last_active', key: 'last_active' } + ]; + } + // user tab + return [ + { title: 'User', dataIndex: 'user_name', key: 'user_name' }, + ...baseValueCols, + { title: 'Last Active', dataIndex: 'last_active', key: 'last_active' } + ]; + }, [activeTableTab]); + + const tableRows: ResourceBreakdownItem[] = tableData?.items ?? []; + + // Export opens a preview modal (matches the Tokens tab): re-filter + preview + // the rows, then download. "Chart" = the by-date trend, "Table" = the active + // bottom-table grouping. + const [exportMode, setExportMode] = useState<'chart' | 'table' | null>(null); + const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format( + 'YYYY-MM-DD' + )}`; + + const chartExportColumns = [ + { title: 'Date', dataIndex: 'date', key: 'date' }, + { + title: 'GPU Hours', + dataIndex: 'gpu_hours', + key: 'gpu_hours', + render: (v: number) => (v ?? 0).toFixed(2) + }, + { + title: 'Instance Hours', + dataIndex: 'instance_hours', + key: 'instance_hours', + render: (v: number) => (v ?? 0).toFixed(2) + }, + { + title: 'Active Instances', + dataIndex: 'active_instances', + key: 'active_instances' + }, + { title: 'Active Users', dataIndex: 'active_users', key: 'active_users' } + ]; + + const tabLabel = TABLE_TABS.find((t) => t.key === activeTableTab)?.label; + const exportConfig = + exportMode === 'chart' + ? { + groupBy: 'date' as const, + columns: chartExportColumns, + fileName: `gpu-instances_chart_${dateSuffix}.xlsx`, + sheetName: 'GPU Instances' + } + : { + groupBy: activeTableTab, + columns: tableColumns, + fileName: `gpu-instances_${activeTableTab}_${dateSuffix}.xlsx`, + sheetName: tabLabel || 'gpu-instances' + }; + + return ( +
+ {/* Top filter row */} + { + setDateRange(dates); + setTablePage(1); + }} + canManageUsers={canManageUsers} + userOptions={userOptions} + selectedUsers={selectedUsers} + onUsersChange={(ids) => { + setSelectedUsers(ids); + setTablePage(1); + }} + resourceFilter={{ + options: instanceOptions, + value: selectedInstances, + onChange: (ids) => { + setSelectedInstances(ids); + setTablePage(1); + }, + placeholder: 'Filter by instance' + }} + onRefresh={() => setRefreshKey((k) => k + 1)} + onExportChart={() => setExportMode('chart')} + onExportTable={() => setExportMode('table')} + /> + + {/* KPI cards */} +
+
+ +
+ + {/* Daily trend chart */} +
+ setMetric(v as Metric)} + onGranularityChange={(v) => setGranularity(v as Granularity)} + seriesData={seriesData} + xAxisData={xAxis} + /> +
+ + {/* Bottom tabs + table */} + { + setActiveTableTab(k as GroupKey); + setTablePage(1); + }} + items={TABLE_TABS.filter( + (t) => t.key !== 'user' || scope === 'all' + ).map((t) => ({ + key: t.key, + label: t.label, + children: ( + + `${row.gpu_type ?? ''}|${row.instance_id ?? ''}|${row.user_id ?? ''}` + } + dataSource={tableRows} + columns={tableColumns as any} + pagination={{ + current: tablePage, + pageSize: tableData?.pagination.perPage ?? 50, + total: tableData?.pagination.total ?? 0, + onChange: (p) => setTablePage(p) + }} + /> + ) + }))} + /> + + setExportMode(null)} + title={ + exportMode === 'chart' + ? 'Export Chart Data' + : `Export Table Data — ${tabLabel}` + } + queryFn={queryGpuInstancesBreakdown} + groupBy={exportConfig.groupBy} + columns={exportConfig.columns} + fileName={exportConfig.fileName} + sheetName={exportConfig.sheetName} + scope={scope} + canManageUsers={canManageUsers} + userOptions={userOptions} + resourceFilter={{ + options: instanceOptions, + placeholder: 'Filter by instance', + key: 'instance_ids' + }} + initialDateRange={dateRange} + initialSelectedUsers={selectedUsers} + initialSelectedResources={selectedInstances} + /> + + ); +}; + +export default GpuInstancesTab; diff --git a/src/pages/usage/components/metric-chart-card.tsx b/src/pages/usage/components/metric-chart-card.tsx new file mode 100644 index 00000000..809793d1 --- /dev/null +++ b/src/pages/usage/components/metric-chart-card.tsx @@ -0,0 +1,117 @@ +/** + * Metric + granularity chart card for the resource-usage tabs. + * + * Mirrors ``DailyUsage`` (the Token tab's chart) so the Resource / GPU + * Instances / Storage tabs share the exact same look — ``CardWrapper`` + + * borderless ``BaseSelect`` with a prefix label + small ``Segmented`` for + * Day/Week/Month — instead of inventing a new style. Decoupled from the + * token data shape: callers pass ready-made bar series. + */ +import BarChart, { BarSeriesItem } from '@/pages/_components/bar-chart'; +import { BaseSelect, CardWrapper } from '@gpustack/core-ui'; +import { useIntl } from '@umijs/max'; +import { Segmented } from 'antd'; +import dayjs from 'dayjs'; +import React from 'react'; +import styled from 'styled-components'; +import { granularities } from '../config'; + +const ControlsWrapper = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + .group { + display: flex; + gap: 8px; + align-items: center; + } +`; + +const ControlLabel = styled.span` + font-size: 14px; + color: var(--ant-color-text-tertiary); + margin-right: 8px; +`; + +interface MetricOption { + value: string; + label: string; +} + +interface MetricChartCardProps { + metric: string; + metricOptions: MetricOption[]; + granularity: string; + onMetricChange: (value: string) => void; + onGranularityChange: (value: string) => void; + seriesData: BarSeriesItem[]; + xAxisData: string[]; +} + +const MetricChartCard: React.FC = ({ + metric, + metricOptions, + granularity, + onMetricChange, + onGranularityChange, + seriesData, + xAxisData +}) => { + const intl = useIntl(); + + const labelFormatter = (v: any) => + granularity === 'hour' + ? dayjs(v).format('MM-DD HH:00') + : granularity === 'month' + ? dayjs(v).format('YYYY-MM') + : dayjs(v).format('MM-DD'); + + return ( + + +
+ + {intl.formatMessage({ id: 'usage.filter.metric' })} + + } + options={metricOptions} + value={metric} + popupMatchSelectWidth={false} + onChange={onMetricChange} + style={{ width: 'max-content' }} + /> +
+ ({ + label: intl.formatMessage({ id: item.label }), + value: item.value + })) + ]} + value={granularity} + onChange={onGranularityChange} + /> +
+ +
+ ); +}; + +export default MetricChartCard; diff --git a/src/pages/usage/components/resource-events.tsx b/src/pages/usage/components/resource-events.tsx new file mode 100644 index 00000000..5569ed12 --- /dev/null +++ b/src/pages/usage/components/resource-events.tsx @@ -0,0 +1,223 @@ +/** + * Resource Events panel — lifecycle event list. + * + * Each row is one ``resource_events`` row: when a GPU instance / PV was + * created, transitioned metered, attached / detached, deleted, etc. + * + * Mounted as a stretch tab in the Usage page. Filter bar matches the Tokens + * tab (date range + "filter by user" for managers); the resource-type / + * event-type selects ride in the bar's ``extra`` slot. + */ +import { useAccess, useIntl } from '@umijs/max'; +import { Select, Table, Tag } from 'antd'; +import dayjs from 'dayjs'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + queryResourceEvents, + ResourceEventItem, + ResourceEventsResponse +} from '../apis/resource'; +import useResourceMeta from '../hooks/use-resource-meta'; +import ResourceFilterBar from './resource-filter-bar'; + +const RESOURCE_TYPE_OPTIONS = [ + { value: 'gpu_instance', label: 'GPU Instance' }, + { value: 'persistent_volume', label: 'Persistent Volume' } +]; + +const EVENT_TYPE_OPTIONS = [ + { value: 'created', label: 'Created' }, + { value: 'deleted', label: 'Deleted' }, + { value: 'phase_to_metered', label: 'Started' }, + { value: 'phase_left_metered', label: 'Stopped' }, + { value: 'updated', label: 'Updated' }, + { value: 'attached', label: 'Attached' }, + { value: 'detached', label: 'Detached' } +]; + +const EVENT_COLOR: Record = { + created: 'green', + deleted: 'red', + phase_to_metered: 'blue', + phase_left_metered: 'orange', + updated: 'cyan', + attached: 'purple', + detached: 'gold' +}; + +// Raw event_type → human-readable label (the same wording as the filter). +// ``phase_to_metered`` / ``phase_left_metered`` bracket the metering window — +// when the resource starts / stops accruing metered uptime (the OSS build only +// meters, it doesn't charge). Users shouldn't see the internal enum names. +const EVENT_LABEL: Record = Object.fromEntries( + EVENT_TYPE_OPTIONS.map((o) => [o.value, o.label]) +); + +const ResourceEvents: React.FC = () => { + const access = useAccess(); + const intl = useIntl(); + const canManageUsers = !!access.canSeeOrgAdmin; + const scope = canManageUsers ? 'all' : 'self'; + + const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([ + dayjs().subtract(29, 'day'), + dayjs() + ]); + const [selectedUsers, setSelectedUsers] = useState([]); + const [resourceTypes, setResourceTypes] = useState([]); + const [eventTypes, setEventTypes] = useState([]); + const [page, setPage] = useState(1); + const [data, setData] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + + const { creators: userOptions } = useResourceMeta(scope); + + const fetch = async () => { + try { + const res = await queryResourceEvents({ + start_date: dateRange[0].format('YYYY-MM-DD'), + end_date: dateRange[1].format('YYYY-MM-DD'), + scope, + filters: selectedUsers.length + ? { creator_ids: selectedUsers } + : undefined, + resource_types: resourceTypes, + event_types: eventTypes, + page, + perPage: 50 + }); + setData(res); + } catch { + // Keep last response on failure. + } + }; + + useEffect(() => { + fetch(); + }, [dateRange, selectedUsers, resourceTypes, eventTypes, page, refreshKey]); + + const columns = useMemo( + () => [ + { + title: 'Time', + dataIndex: 'occurred_at', + key: 'occurred_at', + render: (v: string) => + v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-', + width: 200 + }, + { + title: 'Resource', + dataIndex: 'resource_type', + key: 'resource_type', + render: (v: string) => + v === 'gpu_instance' ? 'GPU Instance' : 'Persistent Volume', + width: 160 + }, + { + title: 'Name', + dataIndex: 'resource_name', + key: 'resource_name' + }, + { + title: 'Event', + dataIndex: 'event_type', + key: 'event_type', + render: (v: string) => ( + {EVENT_LABEL[v] ?? v} + ), + width: 180 + }, + { + title: 'Phase', + dataIndex: 'phase', + key: 'phase', + width: 140 + }, + { + title: 'Creator', + dataIndex: 'creator_name', + key: 'creator_name', + render: (v?: string, row?: ResourceEventItem) => + v ?? (row?.creator_id ? `principal:${row.creator_id}` : '-'), + width: 160 + }, + { + title: 'Message', + dataIndex: 'event_message', + key: 'event_message', + render: (v?: string) => v ?? '-' + } + ], + [] + ); + + return ( +
+ { + setDateRange(dates); + setPage(1); + }} + canManageUsers={canManageUsers} + userOptions={userOptions} + selectedUsers={selectedUsers} + onUsersChange={(ids) => { + setSelectedUsers(ids); + setPage(1); + }} + onRefresh={() => setRefreshKey((k) => k + 1)} + extra={ + <> + { + setEventTypes(v); + setPage(1); + }} + options={EVENT_TYPE_OPTIONS} + style={{ minWidth: 240 }} + /> + + } + /> + +
setPage(p) + }} + /> + + ); +}; + +export default ResourceEvents; diff --git a/src/pages/usage/components/resource-export-data.tsx b/src/pages/usage/components/resource-export-data.tsx new file mode 100644 index 00000000..3dde4613 --- /dev/null +++ b/src/pages/usage/components/resource-export-data.tsx @@ -0,0 +1,260 @@ +/** + * 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 { + exportBreakdownRows, + toExportColumns +} from '../utils/export-breakdown'; +import ResourceFilterBar from './resource-filter-bar'; + +type Scope = 'self' | 'all'; + +interface SelectOption { + value: number; + label: 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[]; +} + +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 + } = props; + const intl = useIntl(); + + const [dateRange, setDateRange] = + useState<[dayjs.Dayjs, dayjs.Dayjs]>(initialDateRange); + const [selectedUsers, setSelectedUsers] = + useState(initialSelectedUsers); + const [selectedResources, setSelectedResources] = useState( + initialSelectedResources + ); + 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 + ? { + ...(selectedUsers.length ? { creator_ids: selectedUsers } : {}), + ...(selectedResources.length + ? { [resourceFilter.key]: selectedResources } + : {}) + } + : 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); + 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, 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] + ); + + const rows: ResourceBreakdownItem[] = data?.items ?? []; + + const handlePageChange = (page: number, perPage: number) => { + setPageParams({ page, perPage }); + }; + + // Export the full filtered set, not just the visible page. + const handleSubmit = async () => { + setExporting(true); + try { + const res = await queryFn(buildRequest(1, 10000)); + exportBreakdownRows( + 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 + }} + /> +
+
`${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; diff --git a/src/pages/usage/components/resource-filter-bar.tsx b/src/pages/usage/components/resource-filter-bar.tsx new file mode 100644 index 00000000..436daa44 --- /dev/null +++ b/src/pages/usage/components/resource-filter-bar.tsx @@ -0,0 +1,212 @@ +/** + * Filter bar shared by the resource tabs (Summary / GPU Instances / Storage / + * Resource Events). Visually and behaviourally mirrors the Tokens tab's + * ``FilterBar``: a date range picker (with the same presets) plus — for users + * who can manage the org — a "filter by user" multi-select. There is no + * explicit All/My scope dropdown; managers default to the org-wide view and + * narrow it via the user select, non-managers only ever see their own rows. + * + * ``extra`` lets a tab append its own filters (e.g. Resource Events' resource + * type / event type) inline, keeping one consistent bar. + */ +import useRangePickerPreset from '@/pages/dashboard/hooks/use-rangepicker-preset'; +import { DownloadOutlined, SyncOutlined } from '@ant-design/icons'; +import { AutoTooltip, IconFont, SimpleSelect } from '@gpustack/core-ui'; +import { useIntl } from '@umijs/max'; +import { Button, DatePicker, Dropdown, MenuProps } from 'antd'; +import dayjs from 'dayjs'; +import React from 'react'; +import FilterBarCss from '../styles/filter-bar.less'; + +const DefaultDateConfig = { + maxRange: 90, + defaultRange: 29 +}; + +interface SelectOption { + value: number; + label: string; +} + +// Optional per-tab entity filter (GPU instance on the GPU tab / volume on the +// Storage tab). Rendered as a multi-select right after the user filter. +interface ResourceEntityFilter { + options: SelectOption[]; + value: number[]; + onChange: (ids: number[]) => void; + placeholder: string; +} + +interface ResourceFilterBarProps { + value: [dayjs.Dayjs, dayjs.Dayjs]; + onChange: (dates: [dayjs.Dayjs, dayjs.Dayjs]) => void; + canManageUsers: boolean; + userOptions: SelectOption[]; + selectedUsers: number[]; + onUsersChange: (ids: number[]) => void; + resourceFilter?: ResourceEntityFilter; + onRefresh?: () => void; + // When provided, an Export dropdown (matching the Tokens tab) is shown with + // "Export Chart Data" / "Export Table Data" entries. + onExportChart?: () => void; + onExportTable?: () => void; + extra?: React.ReactNode; +} + +const ResourceFilterBar: React.FC = (props) => { + const { + value, + onChange, + canManageUsers, + userOptions, + selectedUsers, + onUsersChange, + resourceFilter, + onRefresh, + onExportChart, + onExportTable, + extra + } = props; + const intl = useIntl(); + + const exportMenuItems: MenuProps['items'] = [ + { + key: 'chart', + label: ( + + + {intl.formatMessage({ id: 'usage.export.chart' })} + + ), + onClick: onExportChart + }, + { + key: 'table', + label: ( + + + {intl.formatMessage({ id: 'usage.export.table' })} + + ), + onClick: onExportTable + } + ]; + + const { rangePresets, picker, normalizeRangeValue } = useRangePickerPreset({ + range: DefaultDateConfig.maxRange, + disabledDate: true, + presetRanges: [ + { + label: intl.formatMessage({ + id: 'dashboard.usage.datePicker.last7days' + }), + value: [dayjs().add(-6, 'd'), dayjs()] + }, + { + label: intl.formatMessage({ + id: 'dashboard.usage.datePicker.last30days' + }), + value: [dayjs().add(-29, 'd'), dayjs()] + }, + { + label: intl.formatMessage({ + id: 'dashboard.usage.datePicker.last60days' + }), + value: [dayjs().add(-59, 'd'), dayjs()] + }, + { + label: intl.formatMessage({ + id: 'dashboard.usage.datePicker.last90days' + }), + value: [dayjs().add(-89, 'd'), dayjs()] + } + ] + }); + + // only dates after today should be disabled. + const disabledDate = (current: dayjs.Dayjs) => + current && current > dayjs().endOf('day'); + + const rangePickerValue: [dayjs.Dayjs, dayjs.Dayjs] = + value?.[0] && value?.[1] + ? normalizeRangeValue( + value[0].format('YYYY-MM-DD'), + value[1].format('YYYY-MM-DD'), + picker + ) + : [dayjs().add(-DefaultDateConfig.defaultRange, 'd'), dayjs()]; + + const userOptionRender = (option: any) => ( + + {option?.data?.label} + + ); + + return ( +
+
+ { + if (dates?.[0] && dates?.[1]) { + onChange([dates[0], dates[1]]); + } + }} + /> + {canManageUsers && ( + + )} + {resourceFilter && ( + + )} + {extra} + {onRefresh && ( +
+ {(onExportChart || onExportTable) && ( + +
+ ); +}; + +export default ResourceFilterBar; diff --git a/src/pages/usage/components/storage-tab.tsx b/src/pages/usage/components/storage-tab.tsx new file mode 100644 index 00000000..254a5fc2 --- /dev/null +++ b/src/pages/usage/components/storage-tab.tsx @@ -0,0 +1,396 @@ +/** + * Storage Tab — per-PV capacity usage view. + * + * Mirrors the GPU Instances Tab structure: + * 1. Top filter bar (date range + scope) + * 2. KPI row (GB-Days / GB-Hours / Active Volumes / Dangling Volumes) + * 3. Daily bar chart with metric switch + * 4. Bottom tab table grouped by Volume / User + * + * Talks to ``/usage/storage/{meta,breakdown}``. PV is lifecycle-gated + * (capacity is metered from CREATED to DELETED regardless of attach state), + * so there's no phase filter — just date / scope / volume / user. + */ +import useCoolColors from '@/hooks/use-cool-colors'; +import { formatLargeNumber } from '@/utils'; +import { SimpleCard } from '@gpustack/core-ui'; +import { useAccess } from '@umijs/max'; +import { Table, Tabs } from 'antd'; +import dayjs from 'dayjs'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + queryStorageBreakdown, + ResourceBreakdownItem, + ResourceBreakdownRequest, + ResourceBreakdownResponse +} from '../apis/resource'; +import useResourceMeta from '../hooks/use-resource-meta'; +import { + bucketKey, + generateBucketRange, + Granularity +} from '../utils/time-buckets'; +import MetricChartCard from './metric-chart-card'; +import ResourceExportData from './resource-export-data'; +import ResourceFilterBar from './resource-filter-bar'; + +type Scope = 'self' | 'all'; +type Metric = 'storage_gb_days' | 'storage_gb_hours'; +type GroupKey = 'volume' | 'user'; + +const METRIC_OPTIONS: { value: Metric; label: string }[] = [ + { value: 'storage_gb_days', label: 'GB-Days' }, + { value: 'storage_gb_hours', label: 'GB-Hours' } +]; + +const TABLE_TABS: { key: GroupKey; label: string }[] = [ + { key: 'volume', label: 'Storage' }, + { key: 'user', label: 'Users' } +]; + +const StorageTab: React.FC = () => { + const access = useAccess(); + const coolColors = useCoolColors()(4); + + // No All/My dropdown (matches the Tokens tab): managers see the org-wide + // view and narrow it with the user filter, others only their own rows. + const canManageUsers = !!access.canSeeOrgAdmin; + const scope: Scope = canManageUsers ? 'all' : 'self'; + + const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([ + dayjs().subtract(29, 'day'), + dayjs() + ]); + const [selectedUsers, setSelectedUsers] = useState([]); + const [selectedVolumes, setSelectedVolumes] = useState([]); + const [refreshKey, setRefreshKey] = useState(0); + const [metric, setMetric] = useState('storage_gb_days'); + const [granularity, setGranularity] = useState('day'); + const [activeTableTab, setActiveTableTab] = useState('volume'); + + const { creators: userOptions, volumes: volumeOptions } = + useResourceMeta(scope); + + const [chartData, setChartData] = useState( + null + ); + const [tableData, setTableData] = useState( + null + ); + const [tablePage, setTablePage] = useState(1); + + const baseRequest = (): Omit => ({ + start_date: dateRange[0].format('YYYY-MM-DD'), + end_date: dateRange[1].format('YYYY-MM-DD'), + scope, + granularity, + filters: + selectedUsers.length || selectedVolumes.length + ? { + ...(selectedUsers.length ? { creator_ids: selectedUsers } : {}), + ...(selectedVolumes.length ? { volume_ids: selectedVolumes } : {}) + } + : undefined, + page: 1, + perPage: 50 + }); + + const fetchChart = async () => { + try { + const data = await queryStorageBreakdown({ + ...baseRequest(), + group_by: 'date' + }); + setChartData(data); + } catch { + // Surfacing handled by global interceptor; keep last data. + } + }; + + const fetchTable = async () => { + try { + const data = await queryStorageBreakdown({ + ...baseRequest(), + group_by: activeTableTab, + page: tablePage + }); + setTableData(data); + } catch { + // Same rationale. + } + }; + + useEffect(() => { + fetchChart(); + }, [dateRange, selectedUsers, selectedVolumes, granularity, refreshKey]); + + useEffect(() => { + fetchTable(); + }, [ + dateRange, + selectedUsers, + selectedVolumes, + activeTableTab, + tablePage, + refreshKey + ]); + + const summary = chartData?.summary; + const summaryCards = useMemo( + () => [ + { + label: formatLargeNumber( + Math.round((summary?.storage_gb_days ?? 0) * 10) / 10 + ) as string, + value: 'GB-Days', + color: coolColors[0] + }, + { + label: formatLargeNumber( + Math.round((summary?.storage_gb_hours ?? 0) * 10) / 10 + ) as string, + value: 'GB-Hours', + color: coolColors[1] + }, + { + label: (summary?.active_volumes ?? 0).toString(), + value: 'Storage', + color: coolColors[2] + }, + { + label: (summary?.active_users ?? 0).toString(), + value: 'Active Users', + color: coolColors[3] + } + ], + [summary, coolColors] + ); + + const dataByDate = useMemo(() => { + const map = new Map(); + chartData?.items.forEach((item) => { + if (!item.date) return; + map.set(bucketKey(item.date, granularity), Number(item[metric] ?? 0)); + }); + return map; + }, [chartData, metric, granularity]); + const xAxis = useMemo(() => { + const keys = new Set( + generateBucketRange( + dateRange[0].format('YYYY-MM-DD'), + dateRange[1].format('YYYY-MM-DD'), + granularity + ) + ); + dataByDate.forEach((_v, k) => keys.add(k)); + return Array.from(keys).sort(); + }, [dataByDate, dateRange, granularity]); + + const seriesData = [ + { + name: METRIC_OPTIONS.find((m) => m.value === metric)?.label || metric, + data: xAxis.map((d) => dataByDate.get(d) ?? 0), + color: coolColors[0] + } + ]; + + const tableColumns = useMemo(() => { + const valueCols = [ + { + title: 'GB-Days', + dataIndex: 'storage_gb_days', + key: 'storage_gb_days', + render: (v: number) => (v ?? 0).toFixed(2) + }, + { + title: 'GB-Hours', + dataIndex: 'storage_gb_hours', + key: 'storage_gb_hours', + render: (v: number) => (v ?? 0).toFixed(2) + } + ]; + if (activeTableTab === 'volume') { + return [ + { + title: 'Storage', + dataIndex: 'volume_name', + key: 'volume_name' + }, + ...valueCols, + { title: 'Last Active', dataIndex: 'last_active', key: 'last_active' } + ]; + } + return [ + { title: 'User', dataIndex: 'user_name', key: 'user_name' }, + ...valueCols, + { + title: 'Active Storage', + dataIndex: 'active_volumes', + key: 'active_volumes' + }, + { title: 'Last Active', dataIndex: 'last_active', key: 'last_active' } + ]; + }, [activeTableTab]); + + const tableRows: ResourceBreakdownItem[] = tableData?.items ?? []; + + // Export opens a preview modal (matches the Tokens tab): re-filter + preview + // the rows, then download. "Chart" = the by-date trend, "Table" = the active + // bottom-table grouping. + const [exportMode, setExportMode] = useState<'chart' | 'table' | null>(null); + const dateSuffix = `${dateRange[0].format('YYYY-MM-DD')}_${dateRange[1].format( + 'YYYY-MM-DD' + )}`; + + const chartExportColumns = [ + { title: 'Date', dataIndex: 'date', key: 'date' }, + { + title: 'GB-Days', + dataIndex: 'storage_gb_days', + key: 'storage_gb_days', + render: (v: number) => (v ?? 0).toFixed(2) + }, + { + title: 'GB-Hours', + dataIndex: 'storage_gb_hours', + key: 'storage_gb_hours', + render: (v: number) => (v ?? 0).toFixed(2) + }, + { + title: 'Active Volumes', + dataIndex: 'active_volumes', + key: 'active_volumes' + }, + { title: 'Active Users', dataIndex: 'active_users', key: 'active_users' } + ]; + + const tabLabel = TABLE_TABS.find((t) => t.key === activeTableTab)?.label; + const exportConfig = + exportMode === 'chart' + ? { + groupBy: 'date' as const, + columns: chartExportColumns, + fileName: `storage_chart_${dateSuffix}.xlsx`, + sheetName: 'Storage' + } + : { + groupBy: activeTableTab, + columns: tableColumns, + fileName: `storage_${activeTableTab}_${dateSuffix}.xlsx`, + sheetName: tabLabel || 'storage' + }; + + return ( +
+ { + setDateRange(dates); + setTablePage(1); + }} + canManageUsers={canManageUsers} + userOptions={userOptions} + selectedUsers={selectedUsers} + onUsersChange={(ids) => { + setSelectedUsers(ids); + setTablePage(1); + }} + resourceFilter={{ + options: volumeOptions, + value: selectedVolumes, + onChange: (ids) => { + setSelectedVolumes(ids); + setTablePage(1); + }, + placeholder: 'Filter by storage' + }} + onRefresh={() => setRefreshKey((k) => k + 1)} + onExportChart={() => setExportMode('chart')} + onExportTable={() => setExportMode('table')} + /> +
+ +
+ +
+ +
+ setMetric(v as Metric)} + onGranularityChange={(v) => setGranularity(v as Granularity)} + seriesData={seriesData} + xAxisData={xAxis} + /> +
+ + { + setActiveTableTab(k as GroupKey); + setTablePage(1); + }} + items={TABLE_TABS.filter( + (t) => t.key !== 'user' || scope === 'all' + ).map((t) => ({ + key: t.key, + label: t.label, + children: ( + + `${row.volume_id ?? ''}|${row.user_id ?? ''}|${row.volume_name ?? ''}` + } + dataSource={tableRows} + columns={tableColumns as any} + pagination={{ + current: tablePage, + pageSize: tableData?.pagination.perPage ?? 50, + total: tableData?.pagination.total ?? 0, + onChange: (p) => setTablePage(p) + }} + /> + ) + }))} + /> + + setExportMode(null)} + title={ + exportMode === 'chart' + ? 'Export Chart Data' + : `Export Table Data — ${tabLabel}` + } + queryFn={queryStorageBreakdown} + groupBy={exportConfig.groupBy} + columns={exportConfig.columns} + fileName={exportConfig.fileName} + sheetName={exportConfig.sheetName} + scope={scope} + canManageUsers={canManageUsers} + userOptions={userOptions} + resourceFilter={{ + options: volumeOptions, + placeholder: 'Filter by storage', + key: 'volume_ids' + }} + initialDateRange={dateRange} + initialSelectedUsers={selectedUsers} + initialSelectedResources={selectedVolumes} + /> + + ); +}; + +export default StorageTab; diff --git a/src/pages/usage/components/summary-tab.tsx b/src/pages/usage/components/summary-tab.tsx new file mode 100644 index 00000000..18ce0e22 --- /dev/null +++ b/src/pages/usage/components/summary-tab.tsx @@ -0,0 +1,463 @@ +/** + * Summary Tab — cross-resource overview, organized by domain. + * + * Token and time-based resources carry different detail, so the page is three + * symmetric full-width domain sections instead of a flat KPI stack. Each + * section is identical in shape: a headline stat line, then a breakdown donut + * (left) and a fixed-metric trend (right). A single granularity control at the + * top is shared by all three trends. + * + * ● Tokens headline · Input/Output donut · Tokens-over-time + * ● Compute headline · GPU-type donut · GPU-Hours-over-time + * ● Storage headline · storage-type donut · GB-Days-over-time + * + * Data sources (all share date + scope): + * - /usage/summary → token totals (Input/Output) + GPU-type donut + * - /usage/breakdown (tokens) → Tokens trend (token series is daily-only) + * - /usage/resource/breakdown → Compute trend + active instances + * - /usage/storage/breakdown → Storage trend, volumes, type donut + * + * Shows quantity metrics only (no cost). Donuts use + * each domain's natural unit (tokens / GPU-Hours / GB-Days); a true + * cross-resource split needs a common unit. + */ +import useCoolColors from '@/hooks/use-cool-colors'; +import BarChart from '@/pages/_components/bar-chart'; +import PieChart from '@/pages/_components/pie-chart'; +import { formatLargeNumber } from '@/utils'; +import { useAccess } from '@umijs/max'; +import { Card, Col, Empty, Row } from 'antd'; +import dayjs from 'dayjs'; +import React, { useEffect, useMemo, useState } from 'react'; +import { queryUsageTimeSeriesData } from '../apis'; +import { + queryResourceBreakdown, + queryStorageBreakdown, + queryUsageSummary, + ResourceBreakdownResponse, + UsageSummaryResponse +} from '../apis/resource'; +import useResourceMeta from '../hooks/use-resource-meta'; +import { + bucketKey, + generateBucketRange, + Granularity +} from '../utils/time-buckets'; +import ResourceFilterBar from './resource-filter-bar'; + +type Scope = 'self' | 'all'; + +// Round to at most 2 decimals everywhere (avoid 1.60999999… in the donut center). +const round2 = (n?: number) => Math.round((Number(n) || 0) * 100) / 100; +const fmt = (n?: number) => formatLargeNumber(round2(n)); + +// Tick/tooltip label for a trend's x-axis, per granularity. +const trendLabel = (gran: Granularity) => (v: any) => + gran === 'hour' + ? dayjs(v).format('MM-DD HH:00') + : gran === 'month' + ? dayjs(v).format('YYYY-MM') + : dayjs(v).format('MM-DD'); + +// Collapse {date,value} rows onto a contiguous bucketed x-axis. +const buildTrend = ( + rows: { date?: string; value: number }[], + gran: Granularity, + start: string, + end: string +): { xAxis: string[]; data: number[] } => { + const map = new Map(); + rows.forEach((r) => { + if (!r.date) return; + const k = bucketKey(r.date, gran); + map.set(k, (map.get(k) ?? 0) + r.value); + }); + const keys = new Set(generateBucketRange(start, end, gran)); + map.forEach((_v, k) => keys.add(k)); + const xAxis = Array.from(keys).sort(); + return { xAxis, data: xAxis.map((k) => map.get(k) ?? 0) }; +}; + +// A secondary "label · value" fragment for the headline line. +const Stat: React.FC<{ value: React.ReactNode; label: string }> = ({ + value, + label +}) => ( + + {value}{' '} + + {label} + + +); + +// One compact card per domain: accent + title + headline stats on a single +// row, then a donut (left, legend hugging it) beside a trend (right) that +// carries its own chart title — no wasteful caption rows. +const DomainSection: React.FC<{ + title: string; + accent: string; + headline: React.ReactNode; + donutData: { name: string; value: number }[]; + donutTotalLabel: string; + trendTitle: string; + trendXAxis: string[]; + trendData: number[]; + trendColor: string; + trendGran: Granularity; +}> = ({ + title, + accent, + headline, + donutData, + donutTotalLabel, + trendTitle, + trendXAxis, + trendData, + trendColor, + trendGran +}) => { + const donutTotal = round2(donutData.reduce((s, d) => s + (d.value || 0), 0)); + const trendEmpty = trendData.every((v) => !v); + const empty = ( + + ); + return ( + +
+ + + + {title} + + + + {headline} + +
+
+
+ {donutTotal <= 0 ? ( + empty + ) : ( + + )} +
+
+ {trendEmpty ? ( + empty + ) : ( + + formatLargeNumber(round2(Number(v))) as string + } + /> + )} +
+
+
+ ); +}; + +const SummaryTab: React.FC = () => { + const access = useAccess(); + const coolColors = useCoolColors()(8); + + // No All/My dropdown (matches the Tokens tab): managers see the org-wide + // view and narrow it with the user filter, others only their own rows. + const canManageUsers = !!access.canSeeOrgAdmin; + const scope: Scope = canManageUsers ? 'all' : 'self'; + // Summary trends are fixed to a daily granularity (no granularity control). + const granularity: Granularity = 'day'; + + const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([ + dayjs().subtract(29, 'day'), + dayjs() + ]); + const [selectedUsers, setSelectedUsers] = useState([]); + const [refreshKey, setRefreshKey] = useState(0); + const { creators: userOptions } = useResourceMeta(scope); + + const [summary, setSummary] = useState(null); + const [tokenSeries, setTokenSeries] = useState([]); + const [computeByDate, setComputeByDate] = + useState(null); + const [storageByDate, setStorageByDate] = + useState(null); + const [storageByType, setStorageByType] = + useState(null); + + const start = dateRange[0].format('YYYY-MM-DD'); + const end = dateRange[1].format('YYYY-MM-DD'); + // Token usage (model_usages) is a daily rollup; the Summary trends are fixed + // to ``day`` anyway, so the token trend shares the same granularity. + const tokenGran: Granularity = granularity; + + // "filter by user" — restricts every resource fetch to these creator ids. + const creatorFilter = selectedUsers.length + ? { creator_ids: selectedUsers } + : undefined; + + // Date+scope scoped fetches (granularity-independent). + useEffect(() => { + queryUsageSummary({ + start_date: start, + end_date: end, + scope, + creator_ids: selectedUsers.length ? selectedUsers : undefined + }) + .then(setSummary) + .catch(() => {}); + queryStorageBreakdown({ + start_date: start, + end_date: end, + scope, + group_by: 'type', + filters: creatorFilter, + page: 1, + perPage: 100 + }) + .then(setStorageByType) + .catch(() => {}); + }, [start, end, scope, selectedUsers, refreshKey]); + + // Trend fetches (depend on granularity too). + useEffect(() => { + queryUsageTimeSeriesData({ + start_date: start, + end_date: end, + scope, + metric: 'total_tokens', + group_by: ['date'], + granularity: tokenGran, + filters: {} + }) + .then((res) => setTokenSeries(res.items || [])) + .catch(() => {}); + queryResourceBreakdown({ + start_date: start, + end_date: end, + scope, + group_by: 'date', + granularity, + filters: creatorFilter, + page: 1, + perPage: 100 + }) + .then(setComputeByDate) + .catch(() => {}); + queryStorageBreakdown({ + start_date: start, + end_date: end, + scope, + group_by: 'date', + granularity, + filters: creatorFilter, + page: 1, + perPage: 100 + }) + .then(setStorageByDate) + .catch(() => {}); + }, [start, end, scope, granularity, tokenGran, selectedUsers, refreshKey]); + + // --- derived: donuts --- + const tokenDonut = useMemo( + () => [ + { name: 'Input', value: summary?.input_tokens ?? 0 }, + { name: 'Output', value: summary?.output_tokens ?? 0 } + ], + [summary] + ); + const computeDonut = useMemo( + () => + (summary?.distribution ?? []).map((d) => ({ + name: d.label, + value: d.value + })), + [summary] + ); + const storageDonut = useMemo( + () => + (storageByType?.items ?? []) + .filter((i) => (i.storage_gb_days || 0) > 0) + .map((i) => ({ + name: i.gpu_type || 'unknown', + value: i.storage_gb_days + })), + [storageByType] + ); + + // --- derived: trends --- + const tokenTrend = useMemo( + () => + buildTrend( + tokenSeries.map((it) => ({ + date: it?.date?.value, + value: Number(it?.total_tokens ?? 0) + })), + tokenGran, + start, + end + ), + [tokenSeries, tokenGran, start, end] + ); + const computeTrend = useMemo( + () => + buildTrend( + (computeByDate?.items ?? []).map((it) => ({ + date: it.date, + value: Number(it.gpu_hours ?? 0) + })), + granularity, + start, + end + ), + [computeByDate, granularity, start, end] + ); + const storageTrend = useMemo( + () => + buildTrend( + (storageByDate?.items ?? []).map((it) => ({ + date: it.date, + value: Number(it.storage_gb_days ?? 0) + })), + granularity, + start, + end + ), + [storageByDate, granularity, start, end] + ); + + const computeSum = computeByDate?.summary; + const storageSum = storageByDate?.summary; + + return ( +
+ setDateRange(dates)} + canManageUsers={canManageUsers} + userOptions={userOptions} + selectedUsers={selectedUsers} + onUsersChange={setSelectedUsers} + onRefresh={() => setRefreshKey((k) => k + 1)} + /> +
+ + +
+ + + + + + + } + /> + + + + + + + + } + /> + + + + + + + + + } + /> + + + + ); +}; + +export default SummaryTab; diff --git a/src/pages/usage/components/token-tab.tsx b/src/pages/usage/components/token-tab.tsx new file mode 100644 index 00000000..2861ca2c --- /dev/null +++ b/src/pages/usage/components/token-tab.tsx @@ -0,0 +1,244 @@ +import { baseColorMap } from '@/pages/dashboard/config'; +import { formatLargeNumber } from '@/utils'; +import { SimpleCard } from '@gpustack/core-ui'; +import { useAccess, useIntl } from '@umijs/max'; +import React, { useEffect, useMemo, useState } from 'react'; +import useExportTable from '../hooks/use-export-table'; +import { useUsageFilters } from '../hooks/use-usage-filters'; +import useQueryUsageMetaData from '../services/use-query-meta-data'; +import BreakdownTabs from './breakdown-tabs'; +import DailyUsage from './daily-usage'; +import ExportData from './export-data'; +import FilterBar from './filter-bar'; + +type DateType = 'date' | 'week' | 'month' | 'quarter' | 'year'; + +/** + * Token Usage tab — the original ``/usage`` page wrapped so the parent + * route can host it inside a ``Tabs`` shell alongside Resource / GPU + * Instances / Storage / Summary panes. + * + * Body identical to the previous ``pages/usage/index.tsx``; only the + * relative paths (``./components/...`` → ``./`` / ``./hooks`` → + * ``../hooks`` / ``./services`` → ``../services``) were rewritten. + */ +const TokenTab: React.FC = () => { + const intl = useIntl(); + const access = useAccess(); + const { exportTable } = useExportTable(); + const [openExportModal, setOpenExportModal] = useState(false); + const [breakdownRefreshKey, setBreakdownRefreshKey] = useState(0); + const [breakdownPageResetKey, setBreakdownPageResetKey] = useState(0); + + const summaryColumns = [ + { + title: intl.formatMessage({ id: 'usage.filter.inputTokens' }), + dataIndex: 'input_tokens', + key: 'input_tokens' + }, + { + title: intl.formatMessage({ id: 'usage.filter.outputTokens' }), + dataIndex: 'output_tokens', + key: 'output_tokens' + }, + { + title: intl.formatMessage({ id: 'usage.filter.totalTokens' }), + dataIndex: 'total_tokens', + key: 'total_tokens' + }, + { + title: intl.formatMessage({ id: 'usage.filter.apiRequests' }), + dataIndex: 'api_requests', + key: 'api_requests' + }, + { + title: intl.formatMessage({ id: 'usage.filter.modelsUsed' }), + dataIndex: 'models_called', + key: 'models_called' + } + ]; + + const [chartFilters, setChartFilters] = useState<{ + metric: string; + group_by: string | null; + granularity: string; + }>({ + metric: 'total_tokens', + group_by: null, + granularity: 'day' + }); + + const { detailData: metaData, fetchData: fetchMetaData } = + useQueryUsageMetaData(); + + const { filters, commonFilters, fetchData, timeSeriesData, filterBar } = + useUsageFilters({ + initialScope: access.canSeeOrgAdmin ? 'all' : 'self', + metaData, + chartFilters, + summaryColumns + }); + + useEffect(() => { + fetchMetaData(); + fetchData(commonFilters, chartFilters); + }, []); + + const handleChartFilterChange = (type: string, value: string) => { + setChartFilters((prev) => ({ ...prev, [type]: value })); + fetchData(commonFilters, { ...chartFilters, [type]: value }); + }; + + const summaryCards = useMemo(() => { + const summary = timeSeriesData?.summary || { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + api_requests: 0, + models_called: 0 + }; + return [ + { + label: formatLargeNumber(summary.input_tokens) as string, + value: intl.formatMessage({ id: 'usage.filter.inputTokens' }), + color: baseColorMap.baseR3 + }, + { + label: formatLargeNumber(summary.output_tokens) as string, + value: intl.formatMessage({ id: 'usage.filter.outputTokens' }), + color: baseColorMap.base + }, + { + label: formatLargeNumber(summary.total_tokens) as string, + value: intl.formatMessage({ id: 'usage.filter.totalTokens' }), + color: baseColorMap.baseL1 + }, + { + label: formatLargeNumber(summary.api_requests) as string, + value: intl.formatMessage({ id: 'usage.filter.apiRequests' }), + color: baseColorMap.baseR1 + }, + { + label: summary.models_called.toString(), + value: intl.formatMessage({ id: 'usage.filter.modelsUsed' }), + color: baseColorMap.baseR2 + } + ]; + }, [timeSeriesData.summary]); + + const breakdownDateRange = useMemo( + () => ({ + start_date: commonFilters.start_date, + end_date: commonFilters.end_date + }), + [commonFilters.end_date, commonFilters.start_date] + ); + + const handlePickerChange = (picker: DateType) => { + setChartFilters((prev) => ({ + ...prev, + granularity: picker === 'date' ? 'day' : picker + })); + }; + + const handleExportChart = () => { + setOpenExportModal(true); + }; + + const handleSearch = () => { + filterBar.handleSearch(); + setBreakdownRefreshKey((prev) => prev + 1); + }; + + const handleBreakdownPageReset = () => { + setBreakdownPageResetKey((prev) => prev + 1); + }; + + return ( +
+ { + filterBar.onScopeChange(value); + handleBreakdownPageReset(); + }} + onDateChange={(dates, dateStrings) => { + filterBar.onDateChange(dates, dateStrings); + handleBreakdownPageReset(); + }} + onRoutesChange={(value) => { + filterBar.onRoutesChange(value); + handleBreakdownPageReset(); + }} + onUsersChange={(value) => { + filterBar.onUsersChange(value); + handleBreakdownPageReset(); + }} + onApiKeysChange={(value) => { + filterBar.onApiKeysChange(value); + handleBreakdownPageReset(); + }} + handleSearch={handleSearch} + handlePickerChange={handlePickerChange} + onExportTable={exportTable} + onExportChart={handleExportChart} + /> +
+ +
+ handleChartFilterChange('metric', value)} + onGroupByChange={(value) => + handleChartFilterChange('group_by', value as string) + } + onGranularityChange={(value) => + handleChartFilterChange('granularity', value) + } + /> + + setOpenExportModal(false)} + initialState={{ + activeRoutes: filterBar.selectedRoutes, + activeApiKeys: filterBar.activeApiKeys, + users: commonFilters.users, + start_date: commonFilters.start_date, + end_date: commonFilters.end_date + }} + > +
+ ); +}; + +export default TokenTab; diff --git a/src/pages/usage/index.tsx b/src/pages/usage/index.tsx index c46e2686..ff0427cf 100644 --- a/src/pages/usage/index.tsx +++ b/src/pages/usage/index.tsx @@ -1,242 +1,72 @@ -import { baseColorMap } from '@/pages/dashboard/config'; -import { formatLargeNumber } from '@/utils'; -import { SimpleCard } from '@gpustack/core-ui'; -import { useAccess, useIntl } from '@umijs/max'; -import React, { useEffect, useMemo, useState } from 'react'; -import BreakdownTabs from './components/breakdown-tabs'; -import DailyUsage from './components/daily-usage'; -import ExportData from './components/export-data'; -import FilterBar from './components/filter-bar'; -import useExportTable from './hooks/use-export-table'; -import { useUsageFilters } from './hooks/use-usage-filters'; -import useQueryUsageMetaData from './services/use-query-meta-data'; - -type DateType = 'date' | 'week' | 'month' | 'quarter' | 'year'; +/** + * Usage page — top-level tab shell. + * + * - Summary: cross-resource overview (KPIs + trend + by-type breakdown + donut) + * - Tokens: existing per-request token usage page (untouched) + * - GPU Instances: per-instance compute usage + * - Storage: PV capacity usage + * - Resource Events: lifecycle event list (created / metered / deleted / ...) + * + * Resource Events is kept as a tab (not a separate route) so the page filter + * set stays consistent across all views. + * + * The previous implementation lived in this file; it now lives in + * ``components/token-tab.tsx`` so we can host it as a tab pane. + */ +import { Tabs, TabsProps } from 'antd'; +import React, { useMemo, useState } from 'react'; +import GpuInstancesTab from './components/gpu-instances-tab'; +import ResourceEvents from './components/resource-events'; +import StorageTab from './components/storage-tab'; +import SummaryTab from './components/summary-tab'; +import TokenTab from './components/token-tab'; const Usage: React.FC = () => { - const intl = useIntl(); - const access = useAccess(); - const { exportTable } = useExportTable(); - const [openExportModal, setOpenExportModal] = useState(false); - const [breakdownRefreshKey, setBreakdownRefreshKey] = useState(0); - const [breakdownPageResetKey, setBreakdownPageResetKey] = useState(0); + // Land on the cross-resource Summary by default. + const [activeKey, setActiveKey] = useState('summary'); - const summaryColumns = [ - { - title: intl.formatMessage({ id: 'usage.filter.inputTokens' }), - dataIndex: 'input_tokens', - key: 'input_tokens' - }, - { - title: intl.formatMessage({ id: 'usage.filter.outputTokens' }), - dataIndex: 'output_tokens', - key: 'output_tokens' - }, - { - title: intl.formatMessage({ id: 'usage.filter.totalTokens' }), - dataIndex: 'total_tokens', - key: 'total_tokens' - }, - { - title: intl.formatMessage({ id: 'usage.filter.apiRequests' }), - dataIndex: 'api_requests', - key: 'api_requests' - }, - { - title: intl.formatMessage({ id: 'usage.filter.modelsUsed' }), - dataIndex: 'models_called', - key: 'models_called' - } - ]; - - const [chartFilters, setChartFilters] = useState<{ - metric: string; - group_by: string | null; - granularity: string; - }>({ - metric: 'total_tokens', - group_by: null, - granularity: 'day' - }); - - const { detailData: metaData, fetchData: fetchMetaData } = - useQueryUsageMetaData(); - - const { filters, commonFilters, fetchData, timeSeriesData, filterBar } = - useUsageFilters({ - // ``canSeeOrgAdmin`` widens to Org owners of the selected Org in - // the enterprise build (Personal Org excluded). Mirrors the BE's - // ``_can_use_all_scope`` gate one-to-one. - initialScope: access.canSeeOrgAdmin ? 'all' : 'self', - metaData, - chartFilters, - summaryColumns - }); - - useEffect(() => { - fetchMetaData(); - fetchData(commonFilters, chartFilters); - }, []); - - const handleChartFilterChange = (type: string, value: string) => { - setChartFilters((prev) => ({ ...prev, [type]: value })); - fetchData(commonFilters, { ...chartFilters, [type]: value }); - }; - - const summaryCards = useMemo(() => { - const summary = timeSeriesData?.summary || { - input_tokens: 0, - output_tokens: 0, - total_tokens: 0, - api_requests: 0, - models_called: 0 - }; - return [ + const items: TabsProps['items'] = useMemo( + () => [ { - label: formatLargeNumber(summary.input_tokens) as string, - value: intl.formatMessage({ id: 'usage.filter.inputTokens' }), - color: baseColorMap.baseR3 - // iconType: 'roundRect' + key: 'summary', + label: 'Summary', + children: }, { - label: formatLargeNumber(summary.output_tokens) as string, - value: intl.formatMessage({ id: 'usage.filter.outputTokens' }), - color: baseColorMap.base - // iconType: 'roundRect' + key: 'tokens', + label: 'Tokens', + children: }, { - label: formatLargeNumber(summary.total_tokens) as string, - value: intl.formatMessage({ id: 'usage.filter.totalTokens' }), - color: baseColorMap.baseL1 - // iconType: 'roundRect' + key: 'gpu-instances', + label: 'GPU Instances', + children: }, { - label: formatLargeNumber(summary.api_requests) as string, - value: intl.formatMessage({ id: 'usage.filter.apiRequests' }), - color: baseColorMap.baseR1 - // iconType: 'circle' + key: 'storage', + label: 'Storage', + children: }, { - label: summary.models_called.toString(), - value: intl.formatMessage({ id: 'usage.filter.modelsUsed' }), - color: baseColorMap.baseR2 - // iconType: 'roundRect' + key: 'resource-events', + label: 'Resource Events', + children: } - ]; - }, [timeSeriesData.summary]); - - const breakdownDateRange = useMemo( - () => ({ - start_date: commonFilters.start_date, - end_date: commonFilters.end_date - }), - [commonFilters.end_date, commonFilters.start_date] + ], + [] ); - const handlePickerChange = (picker: DateType) => { - setChartFilters((prev) => ({ - ...prev, - granularity: picker === 'date' ? 'day' : picker - })); - }; - - const handleExportChart = () => { - setOpenExportModal(true); - }; - - const handleSearch = () => { - filterBar.handleSearch(); - setBreakdownRefreshKey((prev) => prev + 1); - }; - - const handleBreakdownPageReset = () => { - setBreakdownPageResetKey((prev) => prev + 1); - }; - return ( -
- { - filterBar.onScopeChange(value); - handleBreakdownPageReset(); - }} - onDateChange={(dates, dateStrings) => { - filterBar.onDateChange(dates, dateStrings); - handleBreakdownPageReset(); - }} - onRoutesChange={(value) => { - filterBar.onRoutesChange(value); - handleBreakdownPageReset(); - }} - onUsersChange={(value) => { - filterBar.onUsersChange(value); - handleBreakdownPageReset(); - }} - onApiKeysChange={(value) => { - filterBar.onApiKeysChange(value); - handleBreakdownPageReset(); - }} - handleSearch={handleSearch} - handlePickerChange={handlePickerChange} - onExportTable={exportTable} - onExportChart={handleExportChart} - /> -
- -
- handleChartFilterChange('metric', value)} - onGroupByChange={(value) => - handleChartFilterChange('group_by', value as string) - } - onGranularityChange={(value) => - handleChartFilterChange('granularity', value) - } - /> - - setOpenExportModal(false)} - initialState={{ - activeRoutes: filterBar.selectedRoutes, - activeApiKeys: filterBar.activeApiKeys, - users: commonFilters.users, - start_date: commonFilters.start_date, - end_date: commonFilters.end_date - }} - > -
+ ); };