-
{label}
-
{value}
+
+
+ {icon}
+ {label}
+
+
{value}
);
}
@@ -84,10 +121,26 @@ function UsageTrendMetricTiles({ stats, billingDisplay }: { stats: UsageTrendSta
const t = useTranslations("settings.subscriptionPage.usageTrend.metrics");
return (
-
-
-
-
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
);
}
@@ -136,46 +189,121 @@ function calculateMonthlyTrendStats(items: BillingUsageMonthlyDTO[]): UsageTrend
};
}
+function aggregateDailyModels(models: BillingUsageDailyDTO["models"], modelLabel: string): DailyUsageChartModel {
+ const totals = models.reduce(
+ (result, model) => ({
+ recordCount: result.recordCount + model.recordCount,
+ inputTokens: result.inputTokens + model.inputTokens,
+ cacheReadTokens: result.cacheReadTokens + model.cacheReadTokens,
+ cacheWriteTokens: result.cacheWriteTokens + model.cacheWriteTokens,
+ outputTokens: result.outputTokens + model.outputTokens,
+ reasoningTokens: result.reasoningTokens + model.reasoningTokens,
+ totalTokens: result.totalTokens + model.totalTokens,
+ callCount: result.callCount + model.callCount,
+ durationSeconds: result.durationSeconds + model.durationSeconds,
+ latency: result.latency + model.avgLatencyMS * model.recordCount,
+ billedNanousd: result.billedNanousd + model.billedNanousd,
+ billedUSD: result.billedUSD + model.billedUSD,
+ }),
+ {
+ recordCount: 0,
+ inputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ outputTokens: 0,
+ reasoningTokens: 0,
+ totalTokens: 0,
+ callCount: 0,
+ durationSeconds: 0,
+ latency: 0,
+ billedNanousd: 0,
+ billedUSD: 0,
+ },
+ );
+ return {
+ platformModelName: OTHER_MODEL_KEY,
+ recordCount: totals.recordCount,
+ inputTokens: totals.inputTokens,
+ cacheReadTokens: totals.cacheReadTokens,
+ cacheWriteTokens: totals.cacheWriteTokens,
+ outputTokens: totals.outputTokens,
+ reasoningTokens: totals.reasoningTokens,
+ totalTokens: totals.totalTokens,
+ callCount: totals.callCount,
+ durationSeconds: totals.durationSeconds,
+ avgLatencyMS: totals.recordCount > 0 ? totals.latency / totals.recordCount : 0,
+ billedNanousd: totals.billedNanousd,
+ billedUSD: totals.billedUSD,
+ modelLabel,
+ };
+}
+
function DailyUsageChartTooltip({
active,
payload,
billingDisplay,
+ hiddenSeries,
}: {
active?: boolean;
payload?: Array<{
payload?: DailyUsageChartPoint;
}>;
billingDisplay: BillingDisplayOptions;
+ hiddenSeries: ReadonlySet
;
}) {
const t = useTranslations("settings.subscriptionPage.usageTrend.tooltip");
const item = payload?.[0]?.payload;
if (!active || !item) {
return null;
}
+ const visibleModels = item.models.filter((model) => !hiddenSeries.has(model.platformModelName || "-"));
+ const visibleMetrics = item.models.length > 0
+ ? visibleModels.reduce(
+ (totals, model) => ({
+ tokens: totals.tokens + model.totalTokens,
+ billedUsd: totals.billedUsd + model.billedUSD,
+ calls: totals.calls + model.callCount,
+ latency: totals.latency + model.avgLatencyMS * model.recordCount,
+ records: totals.records + model.recordCount,
+ }),
+ { tokens: 0, billedUsd: 0, calls: 0, latency: 0, records: 0 },
+ )
+ : {
+ tokens: item.totalTokens,
+ billedUsd: item.billedUsd,
+ calls: item.callCount,
+ latency: item.avgLatencyMS * item.recordCount,
+ records: item.recordCount,
+ };
+ const visibleLatency = visibleMetrics.records > 0 ? visibleMetrics.latency / visibleMetrics.records : 0;
return (
{item.fullDayLabel}
+
+ Tokens
+ {formatTokenCount(visibleMetrics.tokens)}
+
{t("cost")}
- {formatUsageSummaryCost(item.billedUsd, billingDisplay)}
+ {formatUsageSummaryCost(visibleMetrics.billedUsd, billingDisplay)}
{t("calls")}
- {item.callCount.toLocaleString("en-US")}
+ {visibleMetrics.calls.toLocaleString("en-US")}
- Tokens
- {formatTokenCount(item.totalTokens)}
+ {t("latency")}
+ {formatUsageTrendLatency(visibleLatency)}
- {item.models.length > 0 ? (
+ {visibleModels.length > 0 ? (
- {item.models.slice(0, 6).map((model) => (
+ {visibleModels.slice(0, 10).map((model) => (
- {modelDisplayLabel(model)}
+ {model.modelLabel ?? modelDisplayLabel(model)}
{formatTokenCount(model.totalTokens)}
@@ -187,31 +315,6 @@ function DailyUsageChartTooltip({
);
}
-function isTopStackSegment(point: DailyUsageChartPoint, modelSeries: ModelSeries[], modelIndex: number): boolean {
- const current = Number(point[modelSeries[modelIndex]?.key] ?? 0);
- if (current <= 0) return false;
- for (let index = modelIndex + 1; index < modelSeries.length; index += 1) {
- if (Number(point[modelSeries[index].key] ?? 0) > 0) {
- return false;
- }
- }
- return true;
-}
-
-function DailyUsageStackBarShape({
- modelIndex,
- modelSeries,
- props,
-}: {
- modelIndex: number;
- modelSeries: ModelSeries[];
- props: BarShapeProps;
-}) {
- const point: DailyUsageChartPoint | undefined = props.payload;
- const radius: RectangleProps["radius"] = point && isTopStackSegment(point, modelSeries, modelIndex) ? [4, 4, 0, 0] : 0;
- return
;
-}
-
function DailyUsageChart({
items,
loading,
@@ -223,6 +326,13 @@ function DailyUsageChart({
}) {
const t = useTranslations("settings.subscriptionPage.usageTrend");
const { locale } = useAppLocale();
+ const { hiddenSeries, toggleSeries } = useHiddenUsageSeries();
+ const otherModelLabel = t("otherModels");
+ const [todayEndMS] = React.useState(() => {
+ const today = new Date();
+ today.setHours(23, 59, 59, 999);
+ return today.getTime();
+ });
const modelSeries = React.useMemo
(() => {
const totals = new Map();
for (const item of items) {
@@ -236,22 +346,40 @@ function DailyUsageChart({
});
}
}
- return Array.from(totals.entries())
- .sort((left, right) => right[1].totalTokens - left[1].totalTokens || left[0].localeCompare(right[0]))
+ const orderedModels = Array.from(totals.entries())
+ .sort((left, right) => right[1].totalTokens - left[1].totalTokens || left[0].localeCompare(right[0]));
+ const topModels = orderedModels.slice(0, MAX_DAILY_MODEL_SERIES)
.map(([platformModelName, summary], index) => ({
key: `model_${index}`,
platformModelName,
modelLabel: summary.label,
- color: STACK_COLORS[index % STACK_COLORS.length],
+ color: STACK_COLORS[index],
}));
- }, [items]);
+ if (orderedModels.length > MAX_DAILY_MODEL_SERIES) {
+ topModels.push({
+ key: "model_other",
+ platformModelName: OTHER_MODEL_KEY,
+ modelLabel: otherModelLabel,
+ color: OTHER_MODEL_COLOR,
+ });
+ }
+ return topModels;
+ }, [items, otherModelLabel]);
+ const topModelNames = React.useMemo(
+ () => new Set(modelSeries.filter((item) => item.platformModelName !== OTHER_MODEL_KEY).map((item) => item.platformModelName)),
+ [modelSeries],
+ );
const modelKeyByName = React.useMemo(() => new Map(modelSeries.map((item) => [item.platformModelName, item.key])), [modelSeries]);
const modelColorByName = React.useMemo(() => new Map(modelSeries.map((item) => [item.platformModelName, item.color])), [modelSeries]);
const chartData = React.useMemo(
() =>
[...items]
+ .filter((item) => new Date(item.usageDate).getTime() <= todayEndMS)
.sort((left, right) => new Date(left.usageDate).getTime() - new Date(right.usageDate).getTime())
.map((item) => {
+ const models: DailyUsageChartModel[] = (item.models ?? []).filter((model) => topModelNames.has(model.platformModelName || "-"));
+ const otherModels = (item.models ?? []).filter((model) => !topModelNames.has(model.platformModelName || "-"));
+ if (otherModels.length > 0) models.push(aggregateDailyModels(otherModels, otherModelLabel));
const point: DailyUsageChartPoint = {
dayLabel: formatDay(item.usageDate),
fullDayLabel: formatShortDate(item.usageDate, locale),
@@ -260,12 +388,13 @@ function DailyUsageChart({
callCount: item.callCount,
recordCount: item.recordCount,
avgLatencyMS: item.avgLatencyMS,
- models: (item.models ?? []).map((model) => ({
+ models: models.map((model) => ({
...model,
color: modelColorByName.get(model.platformModelName || "-"),
+ modelLabel: model.modelLabel,
})),
};
- for (const model of item.models ?? []) {
+ for (const model of models) {
const key = modelKeyByName.get(model.platformModelName || "-");
if (key) {
point[key] = model.totalTokens;
@@ -273,7 +402,7 @@ function DailyUsageChart({
}
return point;
}),
- [items, locale, modelColorByName, modelKeyByName],
+ [items, locale, modelColorByName, modelKeyByName, otherModelLabel, todayEndMS, topModelNames],
);
const chartConfig = React.useMemo(() => {
if (modelSeries.length === 0) return usageTokenChartConfig;
@@ -281,6 +410,20 @@ function DailyUsageChart({
}, [modelSeries]);
const rangeLabel = chartData.length > 0 ? `${chartData[0].fullDayLabel} - ${chartData[chartData.length - 1].fullDayLabel}` : "";
const hasUsageData = chartData.some((item) => item.billedUsd > 0 || item.totalTokens > 0 || item.callCount > 0 || item.recordCount > 0);
+ const legendItems = React.useMemo(
+ () => modelSeries.length > 0
+ ? modelSeries.map((item) => ({
+ id: item.platformModelName,
+ label: item.modelLabel,
+ color: item.color,
+ }))
+ : [{ id: "totalTokens", label: "Tokens", color: "var(--chart-1)" }],
+ [modelSeries],
+ );
+ const topVisibleModelName = React.useMemo(
+ () => [...modelSeries].reverse().find((item) => !hiddenSeries.has(item.platformModelName))?.platformModelName,
+ [hiddenSeries, modelSeries],
+ );
return (
@@ -291,36 +434,60 @@ function DailyUsageChart({
{loading ?
: null}
{!loading && !hasUsageData ?
{t("empty")}
: null}
{!loading && hasUsageData ? (
-
-
-
-
- formatUsageAxisTokens(value)}
- />
- } />
- {modelSeries.length > 0 ? (
- modelSeries.map((model, modelIndex) => (
+
+
+
+
+
+ formatUsageAxisTokens(value)}
+ />
+ }
+ />
+ {modelSeries.length > 0 ? (
+ modelSeries.map((model) => (
+
+ ))
+ ) : (
(
-
- )}
+ hide={hiddenSeries.has("totalTokens")}
+ isAnimationActive
+ animationDuration={CHART_ANIMATION_DURATION_MS}
+ animationEasing="ease-out"
/>
- ))
- ) : (
-
- )}
-
-
+ )}
+
+
+
+
) : null}
);
@@ -361,6 +528,10 @@ function MonthlyUsageChartTooltip({
{item.fullMonthLabel}
+
+ Tokens
+ {formatTokenCount(item.totalTokens)}
+
{t("cost")}
{formatUsageSummaryCost(item.billedUsd, billingDisplay)}
@@ -370,8 +541,8 @@ function MonthlyUsageChartTooltip({
{item.callCount.toLocaleString("en-US")}
- Tokens
- {formatTokenCount(item.totalTokens)}
+ {t("latency")}
+ {formatUsageTrendLatency(item.avgLatencyMS)}
@@ -389,6 +560,7 @@ function MonthlyUsageChart({
}) {
const t = useTranslations("settings.subscriptionPage.usageTrend");
const { locale } = useAppLocale();
+ const { hiddenSeries, toggleSeries } = useHiddenUsageSeries();
const chartData = React.useMemo(
() =>
[...items]
@@ -406,6 +578,10 @@ function MonthlyUsageChart({
);
const rangeLabel = chartData.length > 0 ? `${chartData[0].fullMonthLabel} - ${chartData[chartData.length - 1].fullMonthLabel}` : "";
const hasUsageData = chartData.some((item) => item.billedUsd > 0 || item.totalTokens > 0 || item.callCount > 0 || item.recordCount > 0);
+ const legendItems = React.useMemo(
+ () => [{ id: "totalTokens", label: "Tokens", color: "var(--chart-1)" }],
+ [],
+ );
return (
@@ -416,15 +592,34 @@ function MonthlyUsageChart({
{loading ?
: null}
{!loading && !hasUsageData ?
{t("empty")}
: null}
{!loading && hasUsageData ? (
-
-
-
-
- formatUsageAxisTokens(value)} />
- } />
-
-
-
+
+
+
+
+
+ formatUsageAxisTokens(value)} />
+ } />
+
+
+
+
+
) : null}
);
@@ -455,22 +650,12 @@ export function SubscriptionTrend({
{view === "daily" ? t("usageTrend.dailyTitle") : t("usageTrend.monthlyTitle")}
-
-
-
-
+
onViewChange(value as UsageTrendView)}>
+
+ {t("usageTrend.daily")}
+ {t("usageTrend.monthly")}
+
+
{view === "daily" ? (
diff --git a/frontend/i18n/messages.ts b/frontend/i18n/messages.ts
index a5fe10d3..9493daf1 100644
--- a/frontend/i18n/messages.ts
+++ b/frontend/i18n/messages.ts
@@ -7,6 +7,7 @@ import enAdminLogin from "@/i18n/messages/en-US/admin-login.json";
import enAdminLogs from "@/i18n/messages/en-US/admin-logs.json";
import enAdminModels from "@/i18n/messages/en-US/admin-models.json";
import enAdminPrompts from "@/i18n/messages/en-US/admin-prompts.json";
+import enAdminStatistics from "@/i18n/messages/en-US/admin-statistics.json";
import enAdminTools from "@/i18n/messages/en-US/admin-tools.json";
import enAdminUpstreams from "@/i18n/messages/en-US/admin-upstreams.json";
import enAdminUsers from "@/i18n/messages/en-US/admin-users.json";
@@ -47,6 +48,7 @@ const ENGLISH_MESSAGES = {
adminLogs: enAdminLogs,
adminModels: enAdminModels,
adminPrompts: enAdminPrompts,
+ adminStatistics: enAdminStatistics,
adminTools: enAdminTools,
adminUpstreams: enAdminUpstreams,
adminUsers: enAdminUsers,
@@ -128,6 +130,7 @@ export async function loadLocaleMessages(locale: AppLocale): Promise