feat(usage): add the tabbed Usage page

A Tabs shell hosting Tokens / GPU Instances / Storage / Summary /
Resource Events:
- token-tab: the original token view, wrapped as a tab.
- gpu-instances-tab & storage-tab: per-instance / per-volume breakdowns
  with KPI cards, a metric+granularity trend chart, and grouped tables.
- summary-tab: three-domain (Tokens / Compute / Storage) overview with
  headline stats, donut, and trend.
- resource-events: the lifecycle audit log (Started / Stopped).
- resource-filter-bar + metric-chart-card: shared filter / chart controls
  mirroring the Tokens tab.
- resource-export-data: a preview modal (filter + paginated preview, then
  download) backing the Export Chart Data / Export Table Data actions.
This commit is contained in:
michelia
2026-06-03 17:10:51 +08:00
committed by michela feng
parent d9c6f2f780
commit 00008abf03
9 changed files with 2388 additions and 223 deletions
@@ -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<number[]>([]);
const [selectedInstances, setSelectedInstances] = useState<number[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const [metric, setMetric] = useState<Metric>('gpu_hours');
const [granularity, setGranularity] = useState<Granularity>('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<GroupKey>('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<ResourceBreakdownResponse | null>(
null
);
const [tableData, setTableData] = useState<ResourceBreakdownResponse | null>(
null
);
const [tablePage, setTablePage] = useState(1);
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
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<string, number>();
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 (
<div>
{/* Top filter row */}
<ResourceFilterBar
value={dateRange}
onChange={(dates) => {
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 */}
<div style={{ height: 24 }} />
<div style={{ marginBottom: 24 }}>
<SimpleCard
dataList={summaryCards}
height={80}
styles={{
item: {
backgroundColor: 'var(--ant-color-fill-quaternary)',
borderRadius: 6
}
}}
/>
</div>
{/* Daily trend chart */}
<div style={{ marginBottom: 24 }}>
<MetricChartCard
metric={metric}
metricOptions={METRIC_OPTIONS}
granularity={granularity}
onMetricChange={(v) => setMetric(v as Metric)}
onGranularityChange={(v) => setGranularity(v as Granularity)}
seriesData={seriesData}
xAxisData={xAxis}
/>
</div>
{/* Bottom tabs + table */}
<Tabs
activeKey={activeTableTab}
onChange={(k) => {
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: (
<Table
rowKey={(row) =>
`${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)
}}
/>
)
}))}
/>
<ResourceExportData
open={exportMode !== null}
onCancel={() => 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}
/>
</div>
);
};
export default GpuInstancesTab;
@@ -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<MetricChartCardProps> = ({
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 (
<CardWrapper style={{ width: '100%', marginTop: 20 }}>
<ControlsWrapper>
<div className="group">
<BaseSelect
variant="borderless"
prefix={
<ControlLabel>
{intl.formatMessage({ id: 'usage.filter.metric' })}
</ControlLabel>
}
options={metricOptions}
value={metric}
popupMatchSelectWidth={false}
onChange={onMetricChange}
style={{ width: 'max-content' }}
/>
</div>
<Segmented
size="small"
options={[
{
label: intl.formatMessage({
id: 'usage.filter.granularity.hour',
defaultMessage: 'Hour'
}),
value: 'hour'
},
...granularities.map((item) => ({
label: intl.formatMessage({ id: item.label }),
value: item.value
}))
]}
value={granularity}
onChange={onGranularityChange}
/>
</ControlsWrapper>
<BarChart
seriesData={seriesData}
xAxisData={xAxisData}
height={280}
labelFormatter={labelFormatter}
/>
</CardWrapper>
);
};
export default MetricChartCard;
@@ -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<string, string> = {
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<string, string> = 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<number[]>([]);
const [resourceTypes, setResourceTypes] = useState<string[]>([]);
const [eventTypes, setEventTypes] = useState<string[]>([]);
const [page, setPage] = useState(1);
const [data, setData] = useState<ResourceEventsResponse | null>(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) => (
<Tag color={EVENT_COLOR[v] ?? 'default'}>{EVENT_LABEL[v] ?? v}</Tag>
),
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 (
<div>
<ResourceFilterBar
value={dateRange}
onChange={(dates) => {
setDateRange(dates);
setPage(1);
}}
canManageUsers={canManageUsers}
userOptions={userOptions}
selectedUsers={selectedUsers}
onUsersChange={(ids) => {
setSelectedUsers(ids);
setPage(1);
}}
onRefresh={() => setRefreshKey((k) => k + 1)}
extra={
<>
<Select
mode="multiple"
allowClear
placeholder={intl.formatMessage({
id: 'usage.events.resourceType',
defaultMessage: 'Resource type'
})}
value={resourceTypes}
onChange={(v) => {
setResourceTypes(v);
setPage(1);
}}
options={RESOURCE_TYPE_OPTIONS}
style={{ minWidth: 200 }}
/>
<Select
mode="multiple"
allowClear
placeholder={intl.formatMessage({
id: 'usage.events.eventType',
defaultMessage: 'Event type'
})}
value={eventTypes}
onChange={(v) => {
setEventTypes(v);
setPage(1);
}}
options={EVENT_TYPE_OPTIONS}
style={{ minWidth: 240 }}
/>
</>
}
/>
<Table
rowKey="id"
dataSource={data?.items ?? []}
columns={columns as any}
style={{ marginTop: 24 }}
pagination={{
current: page,
pageSize: data?.pagination.perPage ?? 50,
total: data?.pagination.total ?? 0,
onChange: (p) => setPage(p)
}}
/>
</div>
);
};
export default ResourceEvents;
@@ -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<ResourceBreakdownResponse>;
groupBy: NonNullable<ResourceBreakdownRequest['group_by']>;
// 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<ResourceExportDataProps> = (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<number[]>(initialSelectedUsers);
const [selectedResources, setSelectedResources] = useState<number[]>(
initialSelectedResources
);
const [pageParams, setPageParams] = useState(INITIAL_PAGE);
const [data, setData] = useState<ResourceBreakdownResponse | null>(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 (
<ScrollerModal
title={title}
open={open}
centered={false}
onCancel={onCancel}
destroyOnHidden={true}
closeIcon={true}
mask={{ closable: false }}
keyboard={false}
width={1000}
style={{ top: '10%' }}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={onCancel}
loading={exporting}
okText={intl.formatMessage({ id: 'common.button.export' })}
></ModalFooter>
}
>
<div
style={{
position: 'sticky',
top: 0,
zIndex: 1,
backgroundColor: 'var(--ant-color-bg-elevated)',
paddingBottom: 8
}}
>
<ResourceFilterBar
value={dateRange}
onChange={(dates) => {
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
}}
/>
</div>
<Table
columns={previewColumns as any}
className={'scroll-table'}
tableLayout={'auto'}
style={{ width: '100%', marginTop: 16, minHeight: 400 }}
dataSource={rows}
rowKey={(_r, index) => `${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
}}
></Table>
</ScrollerModal>
);
};
export default ResourceExportData;
@@ -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<ResourceFilterBarProps> = (props) => {
const {
value,
onChange,
canManageUsers,
userOptions,
selectedUsers,
onUsersChange,
resourceFilter,
onRefresh,
onExportChart,
onExportTable,
extra
} = props;
const intl = useIntl();
const exportMenuItems: MenuProps['items'] = [
{
key: 'chart',
label: (
<span className="flex-center gap-8">
<IconFont type="icon-chart-01" style={{ fontSize: 14 }} />
<span>{intl.formatMessage({ id: 'usage.export.chart' })}</span>
</span>
),
onClick: onExportChart
},
{
key: 'table',
label: (
<span className="flex-center gap-8">
<IconFont type="icon-table" style={{ fontSize: 14 }} />
<span>{intl.formatMessage({ id: 'usage.export.table' })}</span>
</span>
),
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) => (
<span className="flex-center gap-4">
<AutoTooltip ghost>{option?.data?.label}</AutoTooltip>
</span>
);
return (
<div className={FilterBarCss.wrapper}>
<div className={FilterBarCss.filters}>
<DatePicker.RangePicker
maxDate={dayjs()}
value={rangePickerValue}
format={'YYYY-MM-DD'}
picker={picker}
disabledDate={disabledDate}
presets={rangePresets}
allowClear={false}
style={{ width: 240 }}
onChange={(dates) => {
if (dates?.[0] && dates?.[1]) {
onChange([dates[0], dates[1]]);
}
}}
/>
{canManageUsers && (
<SimpleSelect
allowClear
showSearch
mode="multiple"
options={userOptions}
placeholder={intl.formatMessage({ id: 'usage.filter.user' })}
styles={{
wrapper: { flex: 1, maxWidth: 240, minWidth: 150 }
}}
value={selectedUsers}
optionLabelRender={userOptionRender}
onChange={onUsersChange}
/>
)}
{resourceFilter && (
<SimpleSelect
allowClear
showSearch
mode="multiple"
options={resourceFilter.options}
placeholder={resourceFilter.placeholder}
styles={{
wrapper: { flex: 1, maxWidth: 280, minWidth: 160 }
}}
value={resourceFilter.value}
optionLabelRender={userOptionRender}
onChange={resourceFilter.onChange}
/>
)}
{extra}
{onRefresh && (
<Button
type="text"
style={{ color: 'var(--ant-color-text-tertiary)' }}
onClick={onRefresh}
icon={<SyncOutlined />}
/>
)}
</div>
{(onExportChart || onExportTable) && (
<Dropdown menu={{ items: exportMenuItems }}>
<Button icon={<DownloadOutlined />} />
</Dropdown>
)}
</div>
);
};
export default ResourceFilterBar;
+396
View File
@@ -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<number[]>([]);
const [selectedVolumes, setSelectedVolumes] = useState<number[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const [metric, setMetric] = useState<Metric>('storage_gb_days');
const [granularity, setGranularity] = useState<Granularity>('day');
const [activeTableTab, setActiveTableTab] = useState<GroupKey>('volume');
const { creators: userOptions, volumes: volumeOptions } =
useResourceMeta(scope);
const [chartData, setChartData] = useState<ResourceBreakdownResponse | null>(
null
);
const [tableData, setTableData] = useState<ResourceBreakdownResponse | null>(
null
);
const [tablePage, setTablePage] = useState(1);
const baseRequest = (): Omit<ResourceBreakdownRequest, 'group_by'> => ({
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<string, number>();
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 (
<div>
<ResourceFilterBar
value={dateRange}
onChange={(dates) => {
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')}
/>
<div style={{ height: 24 }} />
<div style={{ marginBottom: 24 }}>
<SimpleCard
dataList={summaryCards}
height={80}
styles={{
item: {
backgroundColor: 'var(--ant-color-fill-quaternary)',
borderRadius: 6
}
}}
/>
</div>
<div style={{ marginBottom: 24 }}>
<MetricChartCard
metric={metric}
metricOptions={METRIC_OPTIONS}
granularity={granularity}
onMetricChange={(v) => setMetric(v as Metric)}
onGranularityChange={(v) => setGranularity(v as Granularity)}
seriesData={seriesData}
xAxisData={xAxis}
/>
</div>
<Tabs
activeKey={activeTableTab}
onChange={(k) => {
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: (
<Table
rowKey={(row) =>
`${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)
}}
/>
)
}))}
/>
<ResourceExportData
open={exportMode !== null}
onCancel={() => 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}
/>
</div>
);
};
export default StorageTab;
+463
View File
@@ -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<string, number>();
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
}) => (
<span style={{ whiteSpace: 'nowrap' }}>
<span className="font-600">{value}</span>{' '}
<span className="text-secondary" style={{ fontSize: 13 }}>
{label}
</span>
</span>
);
// 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 = (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="No data"
style={{ margin: '32px 0' }}
/>
);
return (
<Card variant="borderless" styles={{ body: { padding: 16 } }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
rowGap: 4,
flexWrap: 'wrap',
marginBottom: 12
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<span
style={{
width: 4,
height: 14,
borderRadius: 2,
background: accent
}}
/>
<span className="font-600" style={{ fontSize: 15 }}>
{title}
</span>
</span>
<span style={{ display: 'inline-flex', gap: 16, flexWrap: 'wrap' }}>
{headline}
</span>
</div>
<div
style={{
display: 'flex',
gap: 24,
alignItems: 'center',
flexWrap: 'wrap'
}}
>
<div style={{ width: 340, maxWidth: '100%', flexShrink: 0 }}>
{donutTotal <= 0 ? (
empty
) : (
<PieChart
data={donutData}
height={180}
total={donutTotal}
totalLabel={donutTotalLabel}
/>
)}
</div>
<div style={{ flex: 1, minWidth: 260 }}>
{trendEmpty ? (
empty
) : (
<BarChart
seriesData={[
{ name: trendTitle, data: trendData, color: trendColor }
]}
xAxisData={trendXAxis}
height={170}
title={trendTitle}
labelFormatter={trendLabel(trendGran)}
tooltipValueFormatter={(v) =>
formatLargeNumber(round2(Number(v))) as string
}
/>
)}
</div>
</div>
</Card>
);
};
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<number[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const { creators: userOptions } = useResourceMeta(scope);
const [summary, setSummary] = useState<UsageSummaryResponse | null>(null);
const [tokenSeries, setTokenSeries] = useState<any[]>([]);
const [computeByDate, setComputeByDate] =
useState<ResourceBreakdownResponse | null>(null);
const [storageByDate, setStorageByDate] =
useState<ResourceBreakdownResponse | null>(null);
const [storageByType, setStorageByType] =
useState<ResourceBreakdownResponse | null>(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 (
<div>
<ResourceFilterBar
value={dateRange}
onChange={(dates) => setDateRange(dates)}
canManageUsers={canManageUsers}
userOptions={userOptions}
selectedUsers={selectedUsers}
onUsersChange={setSelectedUsers}
onRefresh={() => setRefreshKey((k) => k + 1)}
/>
<div style={{ height: 16 }} />
<Row gutter={[0, 12]}>
<Col span={24}>
<DomainSection
title="Tokens"
accent={coolColors[0]}
donutData={tokenDonut}
donutTotalLabel="Tokens"
trendTitle="Tokens over time"
trendXAxis={tokenTrend.xAxis}
trendData={tokenTrend.data}
trendColor={coolColors[0]}
trendGran={tokenGran}
headline={
<>
<Stat value={fmt(summary?.total_tokens)} label="Tokens" />
<Stat value={fmt(summary?.input_tokens)} label="Input" />
<Stat value={fmt(summary?.output_tokens)} label="Output" />
<Stat
value={summary?.token_active_users ?? 0}
label="Active Users"
/>
</>
}
/>
</Col>
<Col span={24}>
<DomainSection
title="Compute"
accent={coolColors[1]}
donutData={computeDonut}
donutTotalLabel="GPU Hours"
trendTitle="GPU Hours over time"
trendXAxis={computeTrend.xAxis}
trendData={computeTrend.data}
trendColor={coolColors[1]}
trendGran={granularity}
headline={
<>
<Stat value={fmt(summary?.gpu_hours)} label="GPU Hours" />
<Stat
value={computeSum?.active_instances ?? 0}
label="Active Instances"
/>
</>
}
/>
</Col>
<Col span={24}>
<DomainSection
title="Storage"
accent={coolColors[3]}
donutData={storageDonut}
donutTotalLabel="GB-Days"
trendTitle="GB-Days over time"
trendXAxis={storageTrend.xAxis}
trendData={storageTrend.data}
trendColor={coolColors[3]}
trendGran={granularity}
headline={
<>
<Stat value={fmt(summary?.storage_gb_days)} label="GB-Days" />
<Stat
value={storageSum?.active_volumes ?? 0}
label="Active Storage"
/>
<Stat value={storageDonut.length} label="Storage Types" />
</>
}
/>
</Col>
</Row>
</div>
);
};
export default SummaryTab;
+244
View File
@@ -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 (
<div>
<FilterBar
{...filterBar}
onScopeChange={(value) => {
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}
/>
<div
style={{
marginBlock: 24
}}
>
<SimpleCard
dataList={summaryCards}
height={80}
styles={{
item: {
backgroundColor: 'var(--ant-color-fill-quaternary)',
borderRadius: '6px'
}
}}
/>
</div>
<DailyUsage
timeSeriesData={timeSeriesData}
metric={chartFilters.metric}
groupBy={chartFilters.group_by}
granularity={chartFilters.granularity}
startDate={commonFilters.start_date}
endDate={commonFilters.end_date}
onMetricChange={(value) => handleChartFilterChange('metric', value)}
onGroupByChange={(value) =>
handleChartFilterChange('group_by', value as string)
}
onGranularityChange={(value) =>
handleChartFilterChange('granularity', value)
}
/>
<BreakdownTabs
filters={filters}
dateRange={breakdownDateRange}
scope={commonFilters.scope}
pageResetKey={breakdownPageResetKey}
refreshKey={breakdownRefreshKey}
></BreakdownTabs>
<ExportData
metaData={metaData}
granularity={chartFilters.granularity}
initialScope={commonFilters.scope}
commonFilters={commonFilters}
open={openExportModal}
handlePickerChange={handlePickerChange}
onCancel={() => setOpenExportModal(false)}
initialState={{
activeRoutes: filterBar.selectedRoutes,
activeApiKeys: filterBar.activeApiKeys,
users: commonFilters.users,
start_date: commonFilters.start_date,
end_date: commonFilters.end_date
}}
></ExportData>
</div>
);
};
export default TokenTab;
+53 -223
View File
@@ -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<string>('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: <SummaryTab />
},
{
label: formatLargeNumber(summary.output_tokens) as string,
value: intl.formatMessage({ id: 'usage.filter.outputTokens' }),
color: baseColorMap.base
// iconType: 'roundRect'
key: 'tokens',
label: 'Tokens',
children: <TokenTab />
},
{
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: <GpuInstancesTab />
},
{
label: formatLargeNumber(summary.api_requests) as string,
value: intl.formatMessage({ id: 'usage.filter.apiRequests' }),
color: baseColorMap.baseR1
// iconType: 'circle'
key: 'storage',
label: 'Storage',
children: <StorageTab />
},
{
label: summary.models_called.toString(),
value: intl.formatMessage({ id: 'usage.filter.modelsUsed' }),
color: baseColorMap.baseR2
// iconType: 'roundRect'
key: 'resource-events',
label: 'Resource Events',
children: <ResourceEvents />
}
];
}, [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 (
<div>
<FilterBar
{...filterBar}
onScopeChange={(value) => {
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}
/>
<div
style={{
marginBlock: 24
}}
>
<SimpleCard
dataList={summaryCards}
height={80}
styles={{
item: {
backgroundColor: 'var(--ant-color-fill-quaternary)',
borderRadius: '6px'
}
}}
/>
</div>
<DailyUsage
timeSeriesData={timeSeriesData}
metric={chartFilters.metric}
groupBy={chartFilters.group_by}
granularity={chartFilters.granularity}
startDate={commonFilters.start_date}
endDate={commonFilters.end_date}
onMetricChange={(value) => handleChartFilterChange('metric', value)}
onGroupByChange={(value) =>
handleChartFilterChange('group_by', value as string)
}
onGranularityChange={(value) =>
handleChartFilterChange('granularity', value)
}
/>
<BreakdownTabs
filters={filters}
dateRange={breakdownDateRange}
scope={commonFilters.scope}
pageResetKey={breakdownPageResetKey}
refreshKey={breakdownRefreshKey}
></BreakdownTabs>
<ExportData
metaData={metaData}
granularity={chartFilters.granularity}
initialScope={commonFilters.scope}
commonFilters={commonFilters}
open={openExportModal}
handlePickerChange={handlePickerChange}
onCancel={() => setOpenExportModal(false)}
initialState={{
activeRoutes: filterBar.selectedRoutes,
activeApiKeys: filterBar.activeApiKeys,
users: commonFilters.users,
start_date: commonFilters.start_date,
end_date: commonFilters.end_date
}}
></ExportData>
</div>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
items={items}
destroyOnHidden
// The content area adds 24px top padding; on table pages that space is
// filled immediately, but here it sits empty above the tab bar and reads
// as too tall. Pull the tab bar up to ~flush with the header divider.
tabBarStyle={{ marginTop: -20 }}
/>
);
};