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,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;
|
||||
Reference in New Issue
Block a user