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:
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -1,242 +1,72 @@
|
|||||||
import { baseColorMap } from '@/pages/dashboard/config';
|
/**
|
||||||
import { formatLargeNumber } from '@/utils';
|
* Usage page — top-level tab shell.
|
||||||
import { SimpleCard } from '@gpustack/core-ui';
|
*
|
||||||
import { useAccess, useIntl } from '@umijs/max';
|
* - Summary: cross-resource overview (KPIs + trend + by-type breakdown + donut)
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
* - Tokens: existing per-request token usage page (untouched)
|
||||||
import BreakdownTabs from './components/breakdown-tabs';
|
* - GPU Instances: per-instance compute usage
|
||||||
import DailyUsage from './components/daily-usage';
|
* - Storage: PV capacity usage
|
||||||
import ExportData from './components/export-data';
|
* - Resource Events: lifecycle event list (created / metered / deleted / ...)
|
||||||
import FilterBar from './components/filter-bar';
|
*
|
||||||
import useExportTable from './hooks/use-export-table';
|
* Resource Events is kept as a tab (not a separate route) so the page filter
|
||||||
import { useUsageFilters } from './hooks/use-usage-filters';
|
* set stays consistent across all views.
|
||||||
import useQueryUsageMetaData from './services/use-query-meta-data';
|
*
|
||||||
|
* The previous implementation lived in this file; it now lives in
|
||||||
type DateType = 'date' | 'week' | 'month' | 'quarter' | 'year';
|
* ``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 Usage: React.FC = () => {
|
||||||
const intl = useIntl();
|
// Land on the cross-resource Summary by default.
|
||||||
const access = useAccess();
|
const [activeKey, setActiveKey] = useState<string>('summary');
|
||||||
const { exportTable } = useExportTable();
|
|
||||||
const [openExportModal, setOpenExportModal] = useState(false);
|
|
||||||
const [breakdownRefreshKey, setBreakdownRefreshKey] = useState(0);
|
|
||||||
const [breakdownPageResetKey, setBreakdownPageResetKey] = useState(0);
|
|
||||||
|
|
||||||
const summaryColumns = [
|
const items: TabsProps['items'] = useMemo(
|
||||||
{
|
() => [
|
||||||
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 [
|
|
||||||
{
|
{
|
||||||
label: formatLargeNumber(summary.input_tokens) as string,
|
key: 'summary',
|
||||||
value: intl.formatMessage({ id: 'usage.filter.inputTokens' }),
|
label: 'Summary',
|
||||||
color: baseColorMap.baseR3
|
children: <SummaryTab />
|
||||||
// iconType: 'roundRect'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatLargeNumber(summary.output_tokens) as string,
|
key: 'tokens',
|
||||||
value: intl.formatMessage({ id: 'usage.filter.outputTokens' }),
|
label: 'Tokens',
|
||||||
color: baseColorMap.base
|
children: <TokenTab />
|
||||||
// iconType: 'roundRect'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatLargeNumber(summary.total_tokens) as string,
|
key: 'gpu-instances',
|
||||||
value: intl.formatMessage({ id: 'usage.filter.totalTokens' }),
|
label: 'GPU Instances',
|
||||||
color: baseColorMap.baseL1
|
children: <GpuInstancesTab />
|
||||||
// iconType: 'roundRect'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatLargeNumber(summary.api_requests) as string,
|
key: 'storage',
|
||||||
value: intl.formatMessage({ id: 'usage.filter.apiRequests' }),
|
label: 'Storage',
|
||||||
color: baseColorMap.baseR1
|
children: <StorageTab />
|
||||||
// iconType: 'circle'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: summary.models_called.toString(),
|
key: 'resource-events',
|
||||||
value: intl.formatMessage({ id: 'usage.filter.modelsUsed' }),
|
label: 'Resource Events',
|
||||||
color: baseColorMap.baseR2
|
children: <ResourceEvents />
|
||||||
// iconType: 'roundRect'
|
|
||||||
}
|
}
|
||||||
];
|
],
|
||||||
}, [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 (
|
return (
|
||||||
<div>
|
<Tabs
|
||||||
<FilterBar
|
activeKey={activeKey}
|
||||||
{...filterBar}
|
onChange={setActiveKey}
|
||||||
onScopeChange={(value) => {
|
items={items}
|
||||||
filterBar.onScopeChange(value);
|
destroyOnHidden
|
||||||
handleBreakdownPageReset();
|
// 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
|
||||||
onDateChange={(dates, dateStrings) => {
|
// as too tall. Pull the tab bar up to ~flush with the header divider.
|
||||||
filterBar.onDateChange(dates, dateStrings);
|
tabBarStyle={{ marginTop: -20 }}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user