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';
|
||||
import { SimpleCard } from '@gpustack/core-ui';
|
||||
import { useAccess, useIntl } from '@umijs/max';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import BreakdownTabs from './components/breakdown-tabs';
|
||||
import DailyUsage from './components/daily-usage';
|
||||
import ExportData from './components/export-data';
|
||||
import FilterBar from './components/filter-bar';
|
||||
import useExportTable from './hooks/use-export-table';
|
||||
import { useUsageFilters } from './hooks/use-usage-filters';
|
||||
import useQueryUsageMetaData from './services/use-query-meta-data';
|
||||
|
||||
type DateType = 'date' | 'week' | 'month' | 'quarter' | 'year';
|
||||
/**
|
||||
* Usage page — top-level tab shell.
|
||||
*
|
||||
* - Summary: cross-resource overview (KPIs + trend + by-type breakdown + donut)
|
||||
* - Tokens: existing per-request token usage page (untouched)
|
||||
* - GPU Instances: per-instance compute usage
|
||||
* - Storage: PV capacity usage
|
||||
* - Resource Events: lifecycle event list (created / metered / deleted / ...)
|
||||
*
|
||||
* Resource Events is kept as a tab (not a separate route) so the page filter
|
||||
* set stays consistent across all views.
|
||||
*
|
||||
* The previous implementation lived in this file; it now lives in
|
||||
* ``components/token-tab.tsx`` so we can host it as a tab pane.
|
||||
*/
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import GpuInstancesTab from './components/gpu-instances-tab';
|
||||
import ResourceEvents from './components/resource-events';
|
||||
import StorageTab from './components/storage-tab';
|
||||
import SummaryTab from './components/summary-tab';
|
||||
import TokenTab from './components/token-tab';
|
||||
|
||||
const Usage: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const access = useAccess();
|
||||
const { exportTable } = useExportTable();
|
||||
const [openExportModal, setOpenExportModal] = useState(false);
|
||||
const [breakdownRefreshKey, setBreakdownRefreshKey] = useState(0);
|
||||
const [breakdownPageResetKey, setBreakdownPageResetKey] = useState(0);
|
||||
// Land on the cross-resource Summary by default.
|
||||
const [activeKey, setActiveKey] = useState<string>('summary');
|
||||
|
||||
const summaryColumns = [
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.filter.inputTokens' }),
|
||||
dataIndex: 'input_tokens',
|
||||
key: 'input_tokens'
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.filter.outputTokens' }),
|
||||
dataIndex: 'output_tokens',
|
||||
key: 'output_tokens'
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.filter.totalTokens' }),
|
||||
dataIndex: 'total_tokens',
|
||||
key: 'total_tokens'
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.filter.apiRequests' }),
|
||||
dataIndex: 'api_requests',
|
||||
key: 'api_requests'
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({ id: 'usage.filter.modelsUsed' }),
|
||||
dataIndex: 'models_called',
|
||||
key: 'models_called'
|
||||
}
|
||||
];
|
||||
|
||||
const [chartFilters, setChartFilters] = useState<{
|
||||
metric: string;
|
||||
group_by: string | null;
|
||||
granularity: string;
|
||||
}>({
|
||||
metric: 'total_tokens',
|
||||
group_by: null,
|
||||
granularity: 'day'
|
||||
});
|
||||
|
||||
const { detailData: metaData, fetchData: fetchMetaData } =
|
||||
useQueryUsageMetaData();
|
||||
|
||||
const { filters, commonFilters, fetchData, timeSeriesData, filterBar } =
|
||||
useUsageFilters({
|
||||
// ``canSeeOrgAdmin`` widens to Org owners of the selected Org in
|
||||
// the enterprise build (Personal Org excluded). Mirrors the BE's
|
||||
// ``_can_use_all_scope`` gate one-to-one.
|
||||
initialScope: access.canSeeOrgAdmin ? 'all' : 'self',
|
||||
metaData,
|
||||
chartFilters,
|
||||
summaryColumns
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetaData();
|
||||
fetchData(commonFilters, chartFilters);
|
||||
}, []);
|
||||
|
||||
const handleChartFilterChange = (type: string, value: string) => {
|
||||
setChartFilters((prev) => ({ ...prev, [type]: value }));
|
||||
fetchData(commonFilters, { ...chartFilters, [type]: value });
|
||||
};
|
||||
|
||||
const summaryCards = useMemo(() => {
|
||||
const summary = timeSeriesData?.summary || {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
api_requests: 0,
|
||||
models_called: 0
|
||||
};
|
||||
return [
|
||||
const items: TabsProps['items'] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: formatLargeNumber(summary.input_tokens) as string,
|
||||
value: intl.formatMessage({ id: 'usage.filter.inputTokens' }),
|
||||
color: baseColorMap.baseR3
|
||||
// iconType: 'roundRect'
|
||||
key: 'summary',
|
||||
label: 'Summary',
|
||||
children: <SummaryTab />
|
||||
},
|
||||
{
|
||||
label: formatLargeNumber(summary.output_tokens) as string,
|
||||
value: intl.formatMessage({ id: 'usage.filter.outputTokens' }),
|
||||
color: baseColorMap.base
|
||||
// iconType: 'roundRect'
|
||||
key: 'tokens',
|
||||
label: 'Tokens',
|
||||
children: <TokenTab />
|
||||
},
|
||||
{
|
||||
label: formatLargeNumber(summary.total_tokens) as string,
|
||||
value: intl.formatMessage({ id: 'usage.filter.totalTokens' }),
|
||||
color: baseColorMap.baseL1
|
||||
// iconType: 'roundRect'
|
||||
key: 'gpu-instances',
|
||||
label: 'GPU Instances',
|
||||
children: <GpuInstancesTab />
|
||||
},
|
||||
{
|
||||
label: formatLargeNumber(summary.api_requests) as string,
|
||||
value: intl.formatMessage({ id: 'usage.filter.apiRequests' }),
|
||||
color: baseColorMap.baseR1
|
||||
// iconType: 'circle'
|
||||
key: 'storage',
|
||||
label: 'Storage',
|
||||
children: <StorageTab />
|
||||
},
|
||||
{
|
||||
label: summary.models_called.toString(),
|
||||
value: intl.formatMessage({ id: 'usage.filter.modelsUsed' }),
|
||||
color: baseColorMap.baseR2
|
||||
// iconType: 'roundRect'
|
||||
key: 'resource-events',
|
||||
label: 'Resource Events',
|
||||
children: <ResourceEvents />
|
||||
}
|
||||
];
|
||||
}, [timeSeriesData.summary]);
|
||||
|
||||
const breakdownDateRange = useMemo(
|
||||
() => ({
|
||||
start_date: commonFilters.start_date,
|
||||
end_date: commonFilters.end_date
|
||||
}),
|
||||
[commonFilters.end_date, commonFilters.start_date]
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePickerChange = (picker: DateType) => {
|
||||
setChartFilters((prev) => ({
|
||||
...prev,
|
||||
granularity: picker === 'date' ? 'day' : picker
|
||||
}));
|
||||
};
|
||||
|
||||
const handleExportChart = () => {
|
||||
setOpenExportModal(true);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
filterBar.handleSearch();
|
||||
setBreakdownRefreshKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleBreakdownPageReset = () => {
|
||||
setBreakdownPageResetKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FilterBar
|
||||
{...filterBar}
|
||||
onScopeChange={(value) => {
|
||||
filterBar.onScopeChange(value);
|
||||
handleBreakdownPageReset();
|
||||
}}
|
||||
onDateChange={(dates, dateStrings) => {
|
||||
filterBar.onDateChange(dates, dateStrings);
|
||||
handleBreakdownPageReset();
|
||||
}}
|
||||
onRoutesChange={(value) => {
|
||||
filterBar.onRoutesChange(value);
|
||||
handleBreakdownPageReset();
|
||||
}}
|
||||
onUsersChange={(value) => {
|
||||
filterBar.onUsersChange(value);
|
||||
handleBreakdownPageReset();
|
||||
}}
|
||||
onApiKeysChange={(value) => {
|
||||
filterBar.onApiKeysChange(value);
|
||||
handleBreakdownPageReset();
|
||||
}}
|
||||
handleSearch={handleSearch}
|
||||
handlePickerChange={handlePickerChange}
|
||||
onExportTable={exportTable}
|
||||
onExportChart={handleExportChart}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginBlock: 24
|
||||
}}
|
||||
>
|
||||
<SimpleCard
|
||||
dataList={summaryCards}
|
||||
height={80}
|
||||
styles={{
|
||||
item: {
|
||||
backgroundColor: 'var(--ant-color-fill-quaternary)',
|
||||
borderRadius: '6px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DailyUsage
|
||||
timeSeriesData={timeSeriesData}
|
||||
metric={chartFilters.metric}
|
||||
groupBy={chartFilters.group_by}
|
||||
granularity={chartFilters.granularity}
|
||||
startDate={commonFilters.start_date}
|
||||
endDate={commonFilters.end_date}
|
||||
onMetricChange={(value) => handleChartFilterChange('metric', value)}
|
||||
onGroupByChange={(value) =>
|
||||
handleChartFilterChange('group_by', value as string)
|
||||
}
|
||||
onGranularityChange={(value) =>
|
||||
handleChartFilterChange('granularity', value)
|
||||
}
|
||||
/>
|
||||
<BreakdownTabs
|
||||
filters={filters}
|
||||
dateRange={breakdownDateRange}
|
||||
scope={commonFilters.scope}
|
||||
pageResetKey={breakdownPageResetKey}
|
||||
refreshKey={breakdownRefreshKey}
|
||||
></BreakdownTabs>
|
||||
<ExportData
|
||||
metaData={metaData}
|
||||
granularity={chartFilters.granularity}
|
||||
initialScope={commonFilters.scope}
|
||||
commonFilters={commonFilters}
|
||||
open={openExportModal}
|
||||
handlePickerChange={handlePickerChange}
|
||||
onCancel={() => setOpenExportModal(false)}
|
||||
initialState={{
|
||||
activeRoutes: filterBar.selectedRoutes,
|
||||
activeApiKeys: filterBar.activeApiKeys,
|
||||
users: commonFilters.users,
|
||||
start_date: commonFilters.start_date,
|
||||
end_date: commonFilters.end_date
|
||||
}}
|
||||
></ExportData>
|
||||
</div>
|
||||
<Tabs
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
items={items}
|
||||
destroyOnHidden
|
||||
// The content area adds 24px top padding; on table pages that space is
|
||||
// filled immediately, but here it sits empty above the tab bar and reads
|
||||
// as too tall. Pull the tab bar up to ~flush with the header divider.
|
||||
tabBarStyle={{ marginTop: -20 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user