feat(usage): redesign the dashboard with an all-time heatmap and day tiles

- Add an "All" range. Ranges up to 30 days keep the bar chart; "All"
  shows a 53-week daily heatmap. Both default to Tokens and show tokens,
  requests and cost (USD) on hover.
- Rebuild the metric cards: large Token and cost cards plus a six-cell
  strip, replacing the "more metrics" toggle.
- Show today / this week / this month / 7-day average / streak tiles
  under the chart for every range.
- Default the dashboard to the last 24 hours.
- Drop the 5s and 10s auto-refresh options (write events already
  invalidate queries; saved values fall back to 30s), keep the previous
  data while a new range loads, and treat results as fresh for 15s.
This commit is contained in:
Owlbay authored and Jason committed 2026-10-03 23:28:59 +08:00
1 parent c932436dc6
commit cf2e6a717d
16 files changed
+959 -172

No files matched your search

+27 -5
View File
@@ -51,10 +51,13 @@ import { PricingConfigPanel } from "./PricingConfigPanel";
import { RequestDetailPanel } from "./RequestDetailPanel";
import { UsageDataSourcesSheet } from "./UsageDataSourcesSheet";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import { UsageDayTiles, UsageHeatmap } from "./UsageHeatmap";
import { fmtInt, formatRelativeTime, getLocaleFromLanguage } from "./format";
const DEFAULT_REFRESH_INTERVAL_MS = 30000;
const REFRESH_INTERVAL_OPTIONS_MS = [0, 5000, 10000, 30000, 60000] as const;
// 新日志写入时后端会推事件让查询立刻失效,轮询只是兜底,不需要 5 / 10 秒这么密;
// 以前存下的 5 / 10 秒在 normalizeRefreshInterval 里回落到默认 30 秒。
const REFRESH_INTERVAL_OPTIONS_MS = [0, 30000, 60000, 120000] as const;
type RefreshIntervalOption = (typeof REFRESH_INTERVAL_OPTIONS_MS)[number];
const isRefreshIntervalOption = (
@@ -155,8 +158,9 @@ export function UsageDashboard({
}: UsageDashboardProps = {}) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
// 默认看最近 24 小时(按小时分桶),跨零点也能看到连续的用量
const [range, setRange] = useState<UsageRangeSelection>({
preset: "today",
preset: "1d",
});
const [appType, setAppType] = useState<AppTypeFilter>(initialAppType);
const [providerName, setProviderName] = useState<string | undefined>(
@@ -703,9 +707,27 @@ export function UsageDashboard({
compact={compact}
/>
<UsageTrendChart
range={range}
rangeLabel={rangeLabel}
{/* 「全部」看长期分布用热力图;24 小时到 30 天这类短范围用柱状图 */}
{range.preset === "all" ? (
<UsageHeatmap
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
) : (
<UsageTrendChart
range={range}
rangeLabel={rangeLabel}
appType={appType}
providerName={providerName}
model={model}
refreshIntervalMs={refreshIntervalMs}
/>
)}
{/* 今天 / 本周 / 本月 / 近 7 天 / 连续天数:不随时间范围变,两种图下面都显示 */}
<UsageDayTiles
appType={appType}
providerName={providerName}
model={model}
+10 -3
View File
@@ -22,7 +22,7 @@ import type { UsageRangePreset, UsageRangeSelection } from "@/types/usage";
type DraftField = "start" | "end";
const PRESETS: UsageRangePreset[] = ["today", "1d", "7d", "14d", "30d"];
const PRESETS: UsageRangePreset[] = ["today", "1d", "7d", "14d", "30d", "all"];
interface UsageDateRangePickerProps {
selection: UsageRangeSelection;
@@ -105,6 +105,12 @@ function getCalendarDays(month: Date): Date[] {
/* ── component ── */
function resolveCalendarRange(selection: UsageRangeSelection) {
const r = resolveUsageRange(selection);
if (selection.preset !== "all") return r;
return { startDate: r.endDate - 30 * 24 * 60 * 60, endDate: r.endDate };
}
export function UsageDateRangePicker({
selection,
onApply,
@@ -115,8 +121,9 @@ export function UsageDateRangePicker({
const prevMonthLabel = t("usage.prevMonth", { defaultValue: "上个月" });
const nextMonthLabel = t("usage.nextMonth", { defaultValue: "下个月" });
const [activeField, setActiveField] = useState<DraftField>("start");
// 「全部」的起点是 0,日历草稿改从结束日往前 30 天开始,避免跳到 1970 年
const resolvedRange = useMemo(
() => resolveUsageRange(selection),
() => resolveCalendarRange(selection),
[selection],
);
const [draftStart, setDraftStart] = useState(resolvedRange.startDate);
@@ -140,7 +147,7 @@ export function UsageDateRangePicker({
// Reset draft when popover opens
useEffect(() => {
if (!open) return;
const r = resolveUsageRange(selection);
const r = resolveCalendarRange(selection);
setDraftStart(r.startDate);
setDraftEnd(r.endDate);
setDraftLiveEnd(
+472
View File
@@ -0,0 +1,472 @@
import { Fragment, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useUsageTrends } from "@/lib/query/usage";
import { cn } from "@/lib/utils";
import type { UsageRangeSelection } from "@/types/usage";
import {
formatTokensShort,
getLocaleFromLanguage,
parseFiniteNumber,
} from "./format";
import type { UsageTrendStatLike } from "./UsageTrendChart";
import { UsageTooltipCard } from "./UsageTooltipCard";
const WEEKS = 53;
const DAY_MS = 24 * 60 * 60 * 1000;
/** 0 档用底色,1–4 档是同一绿色的不同透明度,深浅色模式都能看清 */
const LEVEL_ALPHA = [0, 0.3, 0.5, 0.75, 1];
type HeatMetric = "tokens" | "requests" | "cost";
interface UsageHeatmapProps {
appType?: string;
providerName?: string;
model?: string;
refreshIntervalMs: number;
}
interface DayCell {
key: string;
date: Date;
value: number;
tokens: number;
requests: number;
cost: number;
future: boolean;
}
interface HoverState {
cell: DayCell;
/** 相对热力图卡片的格子顶部中点 */
x: number;
y: number;
}
function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function dayKey(date: Date): string {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
/** 周一为一周第一天:0 = 周一 … 6 = 周日 */
function weekdayIndex(date: Date): number {
return (date.getDay() + 6) % 7;
}
/** 热力图起点:往前 52 周那一周的周一,凑满 53 列 */
function getHeatmapStart(now: Date): Date {
const today = startOfDay(now);
const thisMonday = new Date(today.getTime() - weekdayIndex(today) * DAY_MS);
return new Date(
thisMonday.getFullYear(),
thisMonday.getMonth(),
thisMonday.getDate() - (WEEKS - 1) * 7,
);
}
function metricValue(stat: UsageTrendStatLike, metric: HeatMetric): number {
if (metric === "requests") return stat.requestCount ?? 0;
if (metric === "cost") return parseFiniteNumber(stat.totalCost) ?? 0;
return (
stat.totalInputTokens +
stat.totalOutputTokens +
stat.totalCacheCreationTokens +
stat.totalCacheReadTokens
);
}
/** 按非零天的四分位分档,避免个别高峰日把其余格子都压成最浅 */
function buildThresholds(values: number[]): number[] {
const sorted = values.filter((v) => v > 0).sort((a, b) => a - b);
if (sorted.length === 0) return [Infinity, Infinity, Infinity];
const at = (q: number) =>
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))];
return [at(0.25), at(0.5), at(0.75)];
}
function levelOf(value: number, thresholds: number[]): number {
if (value <= 0) return 0;
if (value <= thresholds[0]) return 1;
if (value <= thresholds[1]) return 2;
if (value <= thresholds[2]) return 3;
return 4;
}
function cellStyle(level: number) {
return level === 0
? undefined
: { backgroundColor: `rgb(34 197 94 / ${LEVEL_ALPHA[level]})` };
}
/**
* 最近 53 周的按天数据。热力图和日期小卡片共用:起点按天取整,查询键相同,
* 同时挂载也只发一次请求。
*/
export function useYearDailyTrends(
filters: { appType?: string; providerName?: string; model?: string },
refreshIntervalMs: number,
) {
const startKey = dayKey(getHeatmapStart(new Date()));
const selection = useMemo<UsageRangeSelection>(
() => ({
preset: "custom",
customStartDate: Math.floor(getHeatmapStart(new Date()).getTime() / 1000),
liveEndTime: true,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[startKey],
);
return useUsageTrends(selection, filters, {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
});
}
/** 把后端按天的数据铺成 53 周 × 7 天的格子(周一开头,未来的日子标记出来)。 */
function buildWeeks(
trends: UsageTrendStatLike[] | undefined,
metric: HeatMetric,
): DayCell[][] {
const byDay = new Map<string, UsageTrendStatLike>();
for (const stat of trends ?? []) {
byDay.set(dayKey(new Date(stat.date)), stat);
}
const today = startOfDay(new Date());
const start = getHeatmapStart(today);
const weeks: DayCell[][] = [];
for (let w = 0; w < WEEKS; w++) {
const column: DayCell[] = [];
for (let d = 0; d < 7; d++) {
const date = new Date(
start.getFullYear(),
start.getMonth(),
start.getDate() + w * 7 + d,
);
const stat = byDay.get(dayKey(date));
column.push({
key: dayKey(date),
date,
value: stat ? metricValue(stat, metric) : 0,
tokens: stat ? metricValue(stat, "tokens") : 0,
requests: stat?.requestCount ?? 0,
cost: stat ? metricValue(stat, "cost") : 0,
future: date.getTime() > today.getTime(),
});
}
weeks.push(column);
}
return weeks;
}
export function UsageHeatmap({
appType,
providerName,
model,
refreshIntervalMs,
}: UsageHeatmapProps) {
const { t, i18n } = useTranslation();
const [metric, setMetric] = useState<HeatMetric>("tokens");
const sectionRef = useRef<HTMLElement>(null);
const [hover, setHover] = useState<HoverState | null>(null);
const showTooltip = (cell: DayCell, target: HTMLElement) => {
const section = sectionRef.current;
if (!section) return;
const box = section.getBoundingClientRect();
const rect = target.getBoundingClientRect();
setHover({
cell,
x: rect.left + rect.width / 2 - box.left,
y: rect.top - box.top,
});
};
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
const { data: trends, isLoading } = useYearDailyTrends(
{ appType, providerName, model },
refreshIntervalMs,
);
const heat = useMemo(() => {
const weeks = buildWeeks(trends, metric);
const days = weeks.flat().filter((cell) => !cell.future);
const thresholds = buildThresholds(days.map((cell) => cell.value));
// 月份标签:某一列包含当月 1 号(或第一列)时标出月份
const monthLabels = weeks.map((column, index) => {
const first = column.find((cell) => cell.date.getDate() === 1);
if (index === 0) return column[0].date.getMonth() + 1;
return first ? first.date.getMonth() + 1 : null;
});
return { weeks, thresholds, monthLabels };
}, [trends, metric]);
const weekdayLabels = useMemo(() => {
// 2024-01-01 是周一
return Array.from({ length: 7 }, (_, d) =>
new Date(2024, 0, 1 + d).toLocaleDateString(locale, {
weekday: "short",
}),
);
}, [locale]);
return (
<section
ref={sectionRef}
aria-labelledby="usage-heatmap-title"
className="relative shrink-0 rounded-panel border border-border bg-surface px-4 py-3"
>
<div className="flex flex-wrap items-start gap-x-3.5 gap-y-1">
<div>
<h2
id="usage-heatmap-title"
className="m-0 text-body font-semibold text-fg-1"
>
{t("usage.heatmap.title")}
</h2>
<p className="m-0 text-caption text-fg-3">
{t("usage.heatmap.subtitle")}
</p>
</div>
<div className="flex-1" />
<SegmentedControl<HeatMetric>
size="sm"
aria-label={t("usage.trend.metricLabel")}
value={metric}
onValueChange={setMetric}
items={[
{ value: "tokens", label: t("usage.trend.tokens") },
{ value: "requests", label: t("usage.trend.requests") },
{ value: "cost", label: t("usage.trend.cost") },
]}
/>
</div>
{isLoading ? (
<div className="flex h-[150px] items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-3" />
</div>
) : (
<div
className="mt-3 grid items-center gap-[3px]"
style={{
gridTemplateColumns: `max-content repeat(${WEEKS}, minmax(0, 1fr))`,
}}
>
<span />
{heat.weeks.map((_, w) => (
<span
key={`m${w}`}
className="h-4 overflow-visible whitespace-nowrap text-badge leading-4 text-fg-3"
>
{heat.monthLabels[w] != null
? new Date(
2024,
heat.monthLabels[w]! - 1,
1,
).toLocaleDateString(locale, { month: "short" })
: ""}
</span>
))}
{weekdayLabels.map((label, d) => (
<Fragment key={`r${d}`}>
<span className="pe-1.5 text-badge leading-none text-fg-3">
{d % 2 === 0 ? label : ""}
</span>
{heat.weeks.map((column) => {
const cell = column[d];
const level = levelOf(cell.value, heat.thresholds);
return (
<div
key={cell.key}
onMouseEnter={
cell.future
? undefined
: (event) => showTooltip(cell, event.currentTarget)
}
onMouseLeave={() => setHover(null)}
className={cn(
"aspect-square w-full rounded-[3px]",
!cell.future && level === 0 && "bg-subtle",
)}
style={cell.future ? undefined : cellStyle(level)}
/>
);
})}
</Fragment>
))}
</div>
)}
{hover && (
<div
role="tooltip"
className="pointer-events-none absolute z-20"
style={{
left: hover.x,
top: hover.y - 8,
// 靠近左右边缘时往里收,别被卡片裁掉
transform: `translate(${
hover.x < 110
? "-15%"
: hover.x > (sectionRef.current?.clientWidth ?? 0) - 110
? "-85%"
: "-50%"
}, -100%)`,
}}
>
<UsageTooltipCard
heading={hover.cell.date.toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
})}
tokens={hover.cell.tokens}
requests={hover.cell.requests}
cost={hover.cell.cost}
/>
</div>
)}
<div className="mt-2 flex items-center justify-end gap-1.5 text-badge text-fg-3">
{t("usage.heatmap.less")}
{LEVEL_ALPHA.map((_, level) => (
<span
key={level}
className={cn(
"h-2.5 w-2.5 rounded-[2px]",
level === 0 && "bg-subtle",
)}
style={cellStyle(level)}
/>
))}
{t("usage.heatmap.more")}
</div>
</section>
);
}
/**
* 日期小卡片:今天、本周、本月、近 7 天日均、连续使用天数。数据和热力图同源
* (最近 53 周按天),不跟着顶部时间范围走;放在趋势图 / 热力图下面。
*/
export function UsageDayTiles({
appType,
providerName,
model,
refreshIntervalMs,
}: UsageHeatmapProps) {
const { t, i18n } = useTranslation();
const language = i18n.resolvedLanguage || i18n.language || "en";
const { data: trends } = useYearDailyTrends(
{ appType, providerName, model },
refreshIntervalMs,
);
const stats = useMemo(() => {
const days = buildWeeks(trends, "tokens")
.flat()
.filter((cell) => !cell.future);
const today = startOfDay(new Date());
const sum = (cells: DayCell[]) =>
cells.reduce((total, cell) => total + cell.tokens, 0);
const todayIdx = days.length - 1;
const todayCell = days[todayIdx];
const weekStart = today.getTime() - weekdayIndex(today) * DAY_MS;
const last7 = days.slice(-7);
const prev7 = days.slice(-14, -7);
const avg7 = sum(last7) / 7;
const prevAvg7 = sum(prev7) / 7;
// 连续天数:今天还没用也不算断,从昨天往回数
let streak = 0;
let i = todayCell?.requests > 0 ? todayIdx : todayIdx - 1;
for (; i >= 0 && days[i].requests > 0; i--) streak++;
return {
today: todayCell?.tokens ?? 0,
week: sum(days.filter((cell) => cell.date.getTime() >= weekStart)),
month: sum(
days.filter(
(cell) =>
cell.date.getMonth() === today.getMonth() &&
cell.date.getFullYear() === today.getFullYear(),
),
),
monthLabel: today.toLocaleDateString(getLocaleFromLanguage(language), {
month: "long",
}),
avg7,
change: prevAvg7 > 0 ? (avg7 - prevAvg7) / prevAvg7 : null,
streak,
activeDays: days.filter((cell) => cell.requests > 0).length,
};
}, [trends, language]);
const tokens = (value: number) => formatTokensShort(value, language);
const tiles: Array<{ label: string; value: string; hint?: React.ReactNode }> =
[
{ label: t("usage.heatmap.today"), value: tokens(stats.today) },
{
label: t("usage.heatmap.week"),
value: tokens(stats.week),
hint: t("usage.heatmap.weekHint"),
},
{
label: t("usage.heatmap.month"),
value: tokens(stats.month),
hint: stats.monthLabel,
},
{
label: t("usage.heatmap.avg7"),
value: tokens(stats.avg7),
hint:
stats.change == null ? undefined : (
<span>
<span
className={
stats.change >= 0 ? "text-success-text" : "text-danger-text"
}
>
{stats.change >= 0 ? "↑" : "↓"}{" "}
{Math.abs(Math.round(stats.change * 100))}%
</span>{" "}
{t("usage.heatmap.vsPrev")}
</span>
),
},
{
label: t("usage.heatmap.streak"),
value: t("usage.heatmap.days", { count: stats.streak }),
hint: t("usage.heatmap.activeDays", { count: stats.activeDays }),
},
];
return (
<div
data-testid="usage-day-tiles"
className="grid shrink-0 grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5"
>
{tiles.map((tile) => (
<div
key={tile.label}
className="rounded-[14px] border border-border bg-surface px-5 py-4"
>
<div className="text-[14px] text-fg-3">{tile.label}</div>
<div className="mt-2 text-[24px] font-light leading-[1.2] tabular-nums text-fg-1">
{tile.value}
</div>
<div className="mt-1 min-h-[18px] text-caption text-fg-3">
{tile.hint}
</div>
</div>
))}
</div>
);
}
+218 -113
View File
@@ -1,14 +1,11 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import { useUsageSummaryByApp } from "@/lib/query/usage";
import { HelpTip } from "@/components/ui/help-tip";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
fmtInt,
fmtUsd,
formatTokensCompact,
formatTokensShort,
getLocaleFromLanguage,
getResolvedLang,
parseFiniteNumber,
@@ -82,72 +79,167 @@ function pickSummary(
return aggregateSummaries(apps.map((a) => a.summary));
}
function MetricCard({
/** 卡片右侧的大号装饰符号:粗线条、圆头、低对比,越出卡片边缘被裁掉一部分 */
function PowerGlyph({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 100 100"
fill="none"
aria-hidden="true"
className={className}
>
<path
d="M31 27 A34 34 0 1 0 69 27"
stroke="currentColor"
strokeWidth="13"
strokeLinecap="round"
/>
<path
d="M50 10 V44"
stroke="currentColor"
strokeWidth="13"
strokeLinecap="round"
/>
</svg>
);
}
function DollarGlyph({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 100 120"
fill="none"
aria-hidden="true"
className={className}
>
<path
d="M50 8 V112"
stroke="currentColor"
strokeWidth="13"
strokeLinecap="round"
/>
<path
d="M76 34 C72 22 60 20 50 20 C36 20 26 27 26 39 C26 53 40 56 50 59 C62 62 76 66 76 81 C76 93 64 100 50 100 C38 100 28 96 24 85"
stroke="currentColor"
strokeWidth="13"
strokeLinecap="round"
/>
</svg>
);
}
function HeroCard({
label,
help,
value,
title,
details,
glyph: Glyph,
compact,
}: {
label: string;
help?: { title: string; body: string };
value: string;
title?: string;
details: Array<{ label: string; value: string; title?: string }>;
glyph: (props: { className?: string }) => JSX.Element;
compact: boolean;
}) {
return (
<div className="flex h-[76px] min-w-0 flex-col justify-between rounded-panel border border-border bg-surface px-3.5 py-3">
<div className="flex min-w-0 items-center gap-0.5">
<span className="truncate text-caption text-fg-2">{label}</span>
<div
className={cn(
"relative min-w-0 overflow-hidden rounded-[14px] border border-border bg-surface",
compact ? "px-6 py-6" : "px-8 py-8",
)}
>
<Glyph
className={cn(
"pointer-events-none absolute text-fg-1 opacity-[0.07]",
compact
? "-end-6 -top-4 h-[200px] w-[200px]"
: "-end-8 -top-6 h-[300px] w-[300px]",
)}
/>
<div className="relative flex min-w-0 items-center gap-0.5">
<span className="truncate text-[15px] text-fg-2">{label}</span>
{help && (
<HelpTip title={help.title} align="end">
{help.body}
</HelpTip>
)}
</div>
<span
className="truncate text-metric tabular-nums text-fg-1"
<div
className={cn(
// 行高要和字号写在一起:放在前面会被 cn 合并时的字号类冲掉,大字被裁
"relative overflow-hidden text-ellipsis whitespace-nowrap font-light tabular-nums tracking-tight text-fg-1",
compact
? "mt-4 text-[44px] leading-[1.15]"
: "mt-6 text-[72px] leading-[1.1]",
)}
title={title}
>
{value}
</span>
</div>
<div
className={cn(
"relative flex flex-wrap gap-x-6 gap-y-1 text-[14px] text-fg-3",
compact ? "mt-4" : "mt-6",
)}
>
{details.map((item) => (
<span
key={item.label}
className="whitespace-nowrap"
title={item.title}
>
{item.label}{" "}
<span className="tabular-nums text-fg-2">{item.value}</span>
</span>
))}
</div>
</div>
);
}
function MiniMetric({
function StripMetric({
label,
value,
title,
sub,
muted,
help,
}: {
label: string;
value: string;
title?: string;
sub?: string;
muted?: boolean;
help?: { title: string; body: string };
}) {
return (
<div className="flex min-w-0 flex-col rounded-panel bg-subtle px-3.5 py-2">
<div className="flex min-w-0 flex-col px-6 py-5">
<div className="flex min-w-0 items-center gap-0.5">
<span className="truncate text-caption text-fg-2">{label}</span>
<span className="truncate text-[14px] text-fg-3">{label}</span>
{help && <HelpTip title={help.title}>{help.body}</HelpTip>}
</div>
<span
className={cn(
"truncate text-section tabular-nums",
"mt-2 truncate text-[24px] font-light leading-[1.2] tabular-nums",
muted ? "text-fg-3" : "text-fg-1",
)}
title={title}
>
{value}
</span>
{sub && (
<span className="mt-1 truncate text-caption text-fg-3">{sub}</span>
)}
</div>
);
}
/**
* 指标区(v7 S6):默认 4 张卡——总成本、总请求数、真实消耗 Tokens、缓存命中率;
* 「更多指标」展开新增输入 / Output / 创建 / 命中。
* 指标区:上面两张大卡(Token 总量、总成本),下面一条六格指标条。
*/
export function UsageHero({
range,
@@ -159,7 +251,6 @@ export function UsageHero({
}: UsageHeroProps) {
const { t, i18n } = useTranslation();
const locale = getLocaleFromLanguage(getResolvedLang(i18n));
const [moreOpen, setMoreOpen] = useState(false);
const { data, isLoading } = useUsageSummaryByApp(
range,
@@ -199,106 +290,120 @@ export function UsageHero({
? t("usage.cacheWritePartial")
: undefined;
const lang = getResolvedLang(i18n);
const tokens = (value: number) =>
placeholder ?? formatTokensShort(value, lang);
const costPerRequest =
totalCost != null && requests > 0 ? totalCost / requests : null;
const costPerMillion =
totalCost != null && realTotal > 0 ? (totalCost / realTotal) * 1e6 : null;
const successRate = summary?.successRate ?? 0;
return (
<section
aria-label={t("usage.metrics.label")}
className="flex shrink-0 flex-col gap-2.5"
>
<div className="flex items-start gap-2.5">
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<div
className={cn(
"grid gap-2.5",
compact ? "grid-cols-2" : "grid-cols-4",
)}
>
<MetricCard
label={t("usage.totalCost")}
value={
placeholder ?? (totalCost == null ? "--" : fmtUsd(totalCost, 2))
}
title={totalCost == null ? undefined : fmtUsd(totalCost, 6)}
/>
<MetricCard
label={t("usage.totalRequests")}
value={placeholder ?? fmtInt(requests, locale)}
/>
<MetricCard
label={t("usage.realTotal")}
help={{
title: t("usage.metrics.realTotalHelpTitle"),
body: t("usage.metrics.realTotalHelp"),
}}
value={placeholder ?? formatTokensCompact(realTotal, locale)}
title={fmtInt(realTotal, locale)}
/>
<MetricCard
label={t("usage.cacheHitRate")}
help={{
title: t("usage.metrics.hitRateHelpTitle"),
body: t("usage.metrics.hitRateHelp"),
}}
value={placeholder ?? `${hitPercentLabel}%`}
/>
</div>
{moreOpen && (
<div
className={cn(
"grid gap-2.5",
compact ? "grid-cols-2" : "grid-cols-4",
)}
>
<MiniMetric
label={t("usage.freshInput")}
value={formatTokensCompact(input, locale)}
title={fmtInt(input, locale)}
/>
<MiniMetric
label={t("usage.output")}
value={formatTokensCompact(output, locale)}
title={fmtInt(output, locale)}
/>
<MiniMetric
label={t("usage.cacheWrite")}
value={
cacheWriteState === "na"
? "N/A"
: formatTokensCompact(cacheWrite, locale)
}
title={
cacheWriteState === "na" ? undefined : fmtInt(cacheWrite)
}
muted={cacheWriteState === "na"}
help={
cacheWriteHelp
? { title: t("usage.cacheWrite"), body: cacheWriteHelp }
: undefined
}
/>
<MiniMetric
label={t("usage.cacheRead")}
value={formatTokensCompact(cacheRead, locale)}
title={fmtInt(cacheRead, locale)}
/>
</div>
)}
</div>
<Button
type="button"
variant="quiet"
size="regular"
aria-expanded={moreOpen}
onClick={() => setMoreOpen((open) => !open)}
className="shrink-0 gap-1 pe-2 ps-2.5 text-fg-2"
>
{t("usage.metrics.more")}
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform duration-150",
moreOpen && "rotate-180",
)}
/>
</Button>
<div
className={cn("grid gap-2.5", compact ? "grid-cols-1" : "grid-cols-2")}
>
<HeroCard
compact={compact}
glyph={PowerGlyph}
label={t("usage.realTotal")}
help={{
title: t("usage.metrics.realTotalHelpTitle"),
body: t("usage.metrics.realTotalHelp"),
}}
value={tokens(realTotal)}
title={fmtInt(realTotal, locale)}
details={[
{ label: t("usage.freshInput"), value: tokens(input) },
{ label: t("usage.metrics.outputLabel"), value: tokens(output) },
{
label: t("usage.metrics.cacheTotal"),
value: tokens(cacheRead + cacheWrite),
},
]}
/>
<HeroCard
compact={compact}
glyph={DollarGlyph}
label={t("usage.totalCost")}
value={
placeholder ??
(totalCost == null
? "--"
: `$${totalCost.toLocaleString(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`)
}
title={totalCost == null ? undefined : fmtUsd(totalCost, 6)}
details={[
{
label: t("usage.metrics.perRequest"),
value: costPerRequest == null ? "--" : fmtUsd(costPerRequest, 4),
},
{
label: t("usage.metrics.perMillion"),
value: costPerMillion == null ? "--" : fmtUsd(costPerMillion, 2),
},
{
label: t("usage.successRate"),
value: `${successRate.toFixed(successRate >= 99.95 ? 0 : 1)}%`,
},
]}
/>
</div>
<div
className={cn(
"grid divide-border overflow-hidden rounded-[14px] border border-border bg-surface",
compact
? "grid-cols-3 divide-x [&>*:nth-child(n+4)]:border-t"
: "grid-cols-6 divide-x",
)}
>
<StripMetric
label={t("usage.totalRequests")}
value={placeholder ?? fmtInt(requests, locale)}
/>
<StripMetric
label={t("usage.freshInput")}
value={tokens(input)}
title={fmtInt(input, locale)}
/>
<StripMetric
label={t("usage.metrics.outputLabel")}
value={tokens(output)}
title={fmtInt(output, locale)}
/>
<StripMetric
label={t("usage.metrics.cacheReadLabel")}
value={tokens(cacheRead)}
title={fmtInt(cacheRead, locale)}
/>
<StripMetric
label={t("usage.metrics.cacheWriteLabel")}
value={cacheWriteState === "na" ? "N/A" : tokens(cacheWrite)}
title={cacheWriteState === "na" ? undefined : fmtInt(cacheWrite)}
muted={cacheWriteState === "na"}
help={
cacheWriteHelp
? { title: t("usage.cacheWrite"), body: cacheWriteHelp }
: undefined
}
/>
<StripMetric
label={t("usage.cacheHitRate")}
help={{
title: t("usage.metrics.hitRateHelpTitle"),
body: t("usage.metrics.hitRateHelp"),
}}
value={placeholder ?? `${hitPercentLabel}%`}
sub={t("usage.metrics.hitRateSub")}
/>
</div>
</section>
);
+51
View File
@@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next";
import { fmtUsd, formatTokensShort } from "./format";
interface UsageTooltipCardProps {
heading: string;
tokens: number;
requests: number;
/** 美元;null 表示没有可计价的数据 */
cost: number | null;
}
/** 热力图格子、趋势柱的悬停卡片:同一时间段的 Token、请求数和费用一起看。 */
export function UsageTooltipCard({
heading,
tokens,
requests,
cost,
}: UsageTooltipCardProps) {
const { t, i18n } = useTranslation();
const language = i18n.resolvedLanguage || i18n.language || "en";
const rows = [
{
label: t("usage.trend.tokens"),
value: formatTokensShort(tokens, language),
},
{
label: t("usage.trend.requests"),
value: formatTokensShort(requests, language),
},
{
label: t("usage.trend.cost"),
value: cost == null ? "--" : fmtUsd(cost, 2),
},
];
return (
<div className="min-w-[180px] rounded-[10px] border border-border bg-surface px-4 py-3 shadow-v7-lg">
<p className="m-0 mb-2 whitespace-nowrap text-body font-semibold text-fg-1">
{heading}
</p>
<div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1 text-body">
{rows.map((row) => (
<div key={row.label} className="contents">
<span className="text-fg-2">{row.label}</span>
<span className="text-end tabular-nums text-fg-1">{row.value}</span>
</div>
))}
</div>
</div>
);
}
+15 -26
View File
@@ -12,13 +12,9 @@ import {
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useUsageTrends } from "@/lib/query/usage";
import { Loader2 } from "lucide-react";
import {
fmtInt,
fmtUsd,
getLocaleFromLanguage,
parseFiniteNumber,
} from "./format";
import { getLocaleFromLanguage, parseFiniteNumber } from "./format";
import { resolveUsageRange } from "@/lib/usageRange";
import { UsageTooltipCard } from "./UsageTooltipCard";
import type { UsageRangeSelection } from "@/types/usage";
interface UsageTrendChartProps {
@@ -94,8 +90,9 @@ export function buildUsageTrendChartData(
})
: pointDate.toLocaleDateString(dateLocale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
month: "long",
day: "numeric",
weekday: "short",
});
const label = isHourly
? pointDate.toLocaleString(dateLocale, {
@@ -179,7 +176,7 @@ export function UsageTrendChart({
refreshIntervalMs,
}: UsageTrendChartProps) {
const { t, i18n } = useTranslation();
const [metric, setMetric] = useState<TrendMetric>("requests");
const [metric, setMetric] = useState<TrendMetric>("tokens");
const { startDate, endDate } = resolveUsageRange(range);
const { data: trends, isLoading } = useUsageTrends(
range,
@@ -217,31 +214,23 @@ export function UsageTrendChart({
? t("usage.trend.tokens")
: t("usage.trend.cost");
const formatMetric = (value: unknown) =>
metric === "cost" ? fmtUsd(value, 4) : fmtInt(value, dateLocale);
const formatYTick = (value: unknown) =>
metric === "cost"
? `$${parseFiniteNumber(value) ?? 0}`
: formatUsageTrendTokenTickLabel(value, tokenTickFormatter);
// 悬停时 Token、请求、费用一起看,不管当前画的是哪个指标
const renderTooltip = ({ active, payload }: any) => {
if (!active || !payload || payload.length === 0) return null;
const point = payload[0]?.payload as UsageTrendChartPoint | undefined;
const heading = point?.tooltipLabel ?? point?.label ?? "";
if (!point) return null;
return (
<div className="rounded-[8px] border border-border bg-surface px-3 py-2 text-caption text-fg-1 shadow-v7-md">
<p className="mb-1 font-semibold">{heading}</p>
<div className="flex items-center gap-2 tabular-nums">
<span
aria-hidden="true"
className="h-2 w-2 rounded-[2px] bg-chart-1"
/>
<span className="text-fg-2">{metricLabel}</span>
<span className="ms-auto ps-3">
{formatMetric(point ? point[metric] : payload[0]?.value)}
</span>
</div>
</div>
<UsageTooltipCard
heading={point.tooltipLabel ?? point.label}
tokens={point.tokens}
requests={point.requests}
cost={point.cost}
/>
);
};
@@ -274,8 +263,8 @@ export function UsageTrendChart({
value={metric}
onValueChange={setMetric}
items={[
{ value: "requests", label: t("usage.trend.requests") },
{ value: "tokens", label: t("usage.trend.tokens") },
{ value: "requests", label: t("usage.trend.requests") },
{ value: "cost", label: t("usage.trend.cost") },
]}
/>
+23
View File
@@ -1853,6 +1853,7 @@
"preset7d": "Last 7 days",
"preset14d": "Last 14 days",
"preset30d": "Last 30 days",
"presetAll": "All",
"totalRequests": "Total Requests",
"totalCost": "Total Cost",
"cost": "Cost",
@@ -2081,11 +2082,33 @@
"metrics": {
"label": "Metrics",
"more": "More metrics",
"cacheTotal": "Cache",
"perRequest": "Per request",
"perMillion": "Per 1M tokens",
"hitRateSub": "Cache hits / all input",
"outputLabel": "Output",
"cacheReadLabel": "Cache hits",
"cacheWriteLabel": "Cache writes",
"realTotalHelpTitle": "How total tokens are counted",
"realTotalHelp": "Fresh input + output + cache writes + cache hits. Fresh input excludes cache; some protocols (such as OpenAI) don't report cache writes, so that part may be low.",
"hitRateHelpTitle": "How the cache hit rate is calculated",
"hitRateHelp": "Cache hits ÷ (fresh input + cache writes + cache hits)."
},
"heatmap": {
"title": "Usage heatmap",
"subtitle": "Daily usage over the last 53 weeks",
"today": "Today",
"week": "This week",
"month": "This month",
"avg7": "7-day daily avg",
"streak": "Current streak",
"days": "{{count}} days",
"activeDays": "{{count}} active days",
"less": "Less",
"more": "More",
"weekHint": "From Monday",
"vsPrev": "vs prior 7 days"
},
"trend": {
"title": "Usage trend · {{range}}",
"metricLabel": "Trend metric",
+23
View File
@@ -1853,6 +1853,7 @@
"preset7d": "過去 7 日間",
"preset14d": "過去 14 日間",
"preset30d": "過去 30 日間",
"presetAll": "すべて",
"totalRequests": "総リクエスト数",
"totalCost": "総コスト",
"cost": "コスト",
@@ -2081,11 +2082,33 @@
"metrics": {
"label": "指標",
"more": "その他の指標",
"cacheTotal": "キャッシュ",
"perRequest": "1 リクエストあたり",
"perMillion": "100 万トークンあたり",
"hitRateSub": "キャッシュヒット / 全入力",
"outputLabel": "出力",
"cacheReadLabel": "キャッシュヒット",
"cacheWriteLabel": "キャッシュ書き込み",
"realTotalHelpTitle": "実消費 Tokens の計算方法",
"realTotalHelp": "新規入力 + 出力 + キャッシュ書き込み + キャッシュヒット。新規入力にキャッシュは含みません。一部のプロトコル(OpenAI など)はキャッシュ書き込みを報告しないため、その分が少なく出ることがあります。",
"hitRateHelpTitle": "キャッシュヒット率の計算方法",
"hitRateHelp": "キャッシュヒット ÷(新規入力 + キャッシュ書き込み + キャッシュヒット)。"
},
"heatmap": {
"title": "使用量ヒートマップ",
"subtitle": "過去 53 週間の日別使用量",
"today": "今日",
"week": "今週",
"month": "今月",
"avg7": "直近 7 日の日平均",
"streak": "連続日数",
"days": "{{count}} 日",
"activeDays": "計 {{count}} 日稼働",
"less": "少",
"more": "多",
"weekHint": "月曜から",
"vsPrev": "前 7 日比"
},
"trend": {
"title": "使用状況の推移 · {{range}}",
"metricLabel": "推移の指標",
+23
View File
@@ -1854,6 +1854,7 @@
"preset7d": "近 7 天",
"preset14d": "近 14 天",
"preset30d": "近 30 天",
"presetAll": "全部",
"totalRequests": "總請求數",
"totalCost": "總成本",
"cost": "成本",
@@ -2082,11 +2083,33 @@
"metrics": {
"label": "指標",
"more": "更多指標",
"cacheTotal": "快取",
"perRequest": "每次請求",
"perMillion": "每百萬 Token",
"hitRateSub": "快取命中 / 全部輸入",
"outputLabel": "輸出",
"cacheReadLabel": "快取命中",
"cacheWriteLabel": "快取寫入",
"realTotalHelpTitle": "真實消耗 Tokens 怎麼算",
"realTotalHelp": "新增輸入 + 輸出 + 快取寫入 + 快取命中。新增輸入不含快取;部分協定(如 OpenAI)不上報快取寫入,這一項可能偏低。",
"hitRateHelpTitle": "快取命中率怎麼算",
"hitRateHelp": "快取命中 ÷(新增輸入 + 快取寫入 + 快取命中)。"
},
"heatmap": {
"title": "用量熱力圖",
"subtitle": "最近 53 週的每日用量",
"today": "今天",
"week": "本週",
"month": "本月",
"avg7": "近 7 天日均",
"streak": "目前連續",
"days": "{{count}} 天",
"activeDays": "共活躍 {{count}} 天",
"less": "少",
"more": "多",
"weekHint": "週一起",
"vsPrev": "較前 7 天"
},
"trend": {
"title": "使用趨勢 · {{range}}",
"metricLabel": "趨勢指標",
+23
View File
@@ -1853,6 +1853,7 @@
"preset7d": "近 7 天",
"preset14d": "近 14 天",
"preset30d": "近 30 天",
"presetAll": "全部",
"totalRequests": "总请求数",
"totalCost": "总成本",
"cost": "成本",
@@ -2081,11 +2082,33 @@
"metrics": {
"label": "指标",
"more": "更多指标",
"cacheTotal": "缓存",
"perRequest": "每次请求",
"perMillion": "每百万 Token",
"hitRateSub": "缓存命中 / 全部输入",
"outputLabel": "输出",
"cacheReadLabel": "缓存命中",
"cacheWriteLabel": "缓存写入",
"realTotalHelpTitle": "真实消耗 Tokens 怎么算",
"realTotalHelp": "新增输入 + 输出 + 缓存写入 + 缓存命中。新增输入不含缓存;部分协议(如 OpenAI)不上报缓存写入,这一项可能偏低。",
"hitRateHelpTitle": "缓存命中率怎么算",
"hitRateHelp": "缓存命中 ÷(新增输入 + 缓存写入 + 缓存命中)。"
},
"heatmap": {
"title": "用量热力图",
"subtitle": "最近 53 周的每日用量",
"today": "今天",
"week": "本周",
"month": "本月",
"avg7": "近 7 天日均",
"streak": "当前连续",
"days": "{{count}} 天",
"activeDays": "共活跃 {{count}} 天",
"less": "少",
"more": "多",
"weekHint": "周一起",
"vsPrev": "较前 7 天"
},
"trend": {
"title": "使用趋势 · {{range}}",
"metricLabel": "趋势指标",
+31 -1
View File
@@ -1,4 +1,9 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
keepPreviousData,
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage";
import { resolveUsageRange } from "@/lib/usageRange";
import type {
@@ -8,6 +13,7 @@ import type {
} from "@/types/usage";
const DEFAULT_REFETCH_INTERVAL_MS = 30000;
const USAGE_STALE_TIME_MS = 15000;
type UsageQueryOptions = {
refetchInterval?: number | false;
@@ -185,6 +191,10 @@ export function useUsageSummary(
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
// 切换时间范围 / 筛选时先留着上一份数据,不闪成加载态;
// 15 秒内重新挂载不重复查(写入事件仍会让它立刻失效)
placeholderData: keepPreviousData,
staleTime: USAGE_STALE_TIME_MS,
});
}
@@ -212,6 +222,10 @@ export function useUsageSummaryByApp(
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
// 切换时间范围 / 筛选时先留着上一份数据,不闪成加载态;
// 15 秒内重新挂载不重复查(写入事件仍会让它立刻失效)
placeholderData: keepPreviousData,
staleTime: USAGE_STALE_TIME_MS,
});
}
@@ -241,6 +255,10 @@ export function useUsageTrends(
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
// 切换时间范围 / 筛选时先留着上一份数据,不闪成加载态;
// 15 秒内重新挂载不重复查(写入事件仍会让它立刻失效)
placeholderData: keepPreviousData,
staleTime: USAGE_STALE_TIME_MS,
});
}
@@ -270,6 +288,10 @@ export function useProviderStats(
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
// 切换时间范围 / 筛选时先留着上一份数据,不闪成加载态;
// 15 秒内重新挂载不重复查(写入事件仍会让它立刻失效)
placeholderData: keepPreviousData,
staleTime: USAGE_STALE_TIME_MS,
});
}
@@ -299,6 +321,10 @@ export function useModelStats(
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
// 切换时间范围 / 筛选时先留着上一份数据,不闪成加载态;
// 15 秒内重新挂载不重复查(写入事件仍会让它立刻失效)
placeholderData: keepPreviousData,
staleTime: USAGE_STALE_TIME_MS,
});
}
@@ -328,6 +354,10 @@ export function useRequestLogs({
},
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
// 切换时间范围 / 筛选时先留着上一份数据,不闪成加载态;
// 15 秒内重新挂载不重复查(写入事件仍会让它立刻失效)
placeholderData: keepPreviousData,
staleTime: USAGE_STALE_TIME_MS,
});
}
+6 -1
View File
@@ -14,7 +14,7 @@ function getStartOfLocalDayDate(nowMs: number): Date {
}
function getPresetLookbackStart(
preset: Exclude<UsageRangePreset, "today" | "1d" | "custom">,
preset: Exclude<UsageRangePreset, "today" | "1d" | "all" | "custom">,
nowMs: number,
): number {
const dayCount = preset === "7d" ? 7 : preset === "14d" ? 14 : 30;
@@ -47,6 +47,9 @@ export function resolveUsageRange(
startDate: getPresetLookbackStart(selection.preset, nowMs),
endDate,
};
// 全部:从最早的记录算起,汇总和明细表按全量统计
case "all":
return { startDate: 0, endDate };
case "custom": {
const startDate = selection.customStartDate ?? endDate - DAY_SECONDS;
const customEndDate = selection.liveEndTime
@@ -75,6 +78,8 @@ export function getUsageRangePresetLabel(
return t("usage.preset14d", { defaultValue: "14d" });
case "30d":
return t("usage.preset30d", { defaultValue: "30d" });
case "all":
return t("usage.presetAll", { defaultValue: "全部" });
case "custom":
return t("usage.customRange", { defaultValue: "日历筛选" });
}
+8 -1
View File
@@ -170,7 +170,14 @@ export interface ProviderLimitStatus {
monthlyExceeded: boolean;
}
export type UsageRangePreset = "today" | "1d" | "7d" | "14d" | "30d" | "custom";
export type UsageRangePreset =
| "today"
| "1d"
| "7d"
| "14d"
| "30d"
| "all"
| "custom";
export interface UsageRangeSelection {
preset: UsageRangePreset;
@@ -144,19 +144,16 @@ describe("UsageDashboard (smoke)", () => {
// 指标卡
expect(await screen.findByText("$42.10")).toBeInTheDocument();
expect(screen.getByText("18.2M")).toBeInTheDocument();
// 中文按「万 / 亿」显示:18,200,000 → 1820.0 万
expect(screen.getByText("1820.0 万")).toBeInTheDocument();
// 「全部」时由各应用的 token 重新算:16.6M ÷ (1M + 0.2M + 16.6M)
expect(screen.getByText("93.3%")).toBeInTheDocument();
expect(screen.getByText("usage.trend.title")).toBeInTheDocument();
// 更多指标
await user.click(
screen.getByRole("button", { name: "usage.metrics.more" }),
);
// 指标条直接显示缓存写入,不再需要展开
expect(
screen.getByRole("button", { name: "usage.metrics.more" }),
).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("usage.cacheWrite")).toBeInTheDocument();
screen.getByText("usage.metrics.cacheWriteLabel"),
).toBeInTheDocument();
// 供应商:汇总速度 92000 / 1000 s = 92 tok/s
await user.click(screen.getByRole("tab", { name: "usage.tabs.providers" }));
+20 -10
View File
@@ -169,23 +169,23 @@ describe("UsageDashboard", () => {
});
it("shows the saved refresh interval in the header menu", () => {
renderDashboard({ refreshIntervalMs: 5000 });
renderDashboard({ refreshIntervalMs: 60000 });
expect(
screen.getByRole("button", {
name: "usage.refreshInterval: usage.refreshMenu.label:5",
name: "usage.refreshInterval: usage.refreshMenu.label:60",
}),
).toBeInTheDocument();
});
it("defaults to today", () => {
it("defaults to the last 24 hours", () => {
renderDashboard();
expect(
screen.getByRole("button", { name: "usage.presetToday" }),
screen.getByRole("button", { name: "usage.preset1d" }),
).toBeInTheDocument();
expect(usageHeroMock).toHaveBeenLastCalledWith(
expect.objectContaining({ range: { preset: "today" } }),
expect.objectContaining({ range: { preset: "1d" } }),
);
});
@@ -245,6 +245,16 @@ describe("UsageDashboard", () => {
);
});
it("falls back to 30 seconds for a retired 5-second interval", () => {
renderDashboard({ refreshIntervalMs: 5000 });
expect(
screen.getByRole("button", {
name: "usage.refreshInterval: usage.refreshMenu.label:30",
}),
).toBeInTheDocument();
});
it("persists refresh interval changes", async () => {
const user = userEvent.setup();
const onRefreshIntervalChange = vi.fn().mockResolvedValue(true);
@@ -257,16 +267,16 @@ describe("UsageDashboard", () => {
);
await user.click(
await screen.findByRole("menuitem", {
name: "usage.refreshMenu.seconds:5",
name: "usage.refreshMenu.seconds:60",
}),
);
await waitFor(() =>
expect(onRefreshIntervalChange).toHaveBeenCalledWith(5000),
expect(onRefreshIntervalChange).toHaveBeenCalledWith(60000),
);
expect(
screen.getByRole("button", {
name: "usage.refreshInterval: usage.refreshMenu.label:5",
name: "usage.refreshInterval: usage.refreshMenu.label:60",
}),
).toBeInTheDocument();
});
@@ -283,12 +293,12 @@ describe("UsageDashboard", () => {
);
await user.click(
await screen.findByRole("menuitem", {
name: "usage.refreshMenu.seconds:5",
name: "usage.refreshMenu.seconds:60",
}),
);
await waitFor(() =>
expect(onRefreshIntervalChange).toHaveBeenCalledWith(5000),
expect(onRefreshIntervalChange).toHaveBeenCalledWith(60000),
);
await waitFor(() =>
expect(
@@ -39,8 +39,8 @@ describe("UsageTrendChart (single metric)", () => {
);
const legend = screen.getByTestId("usage-trend-legend");
// 默认「请求」,且不再有第二条「成本(右轴)」图例
expect(legend).toHaveTextContent("usage.trend.requestsLegend");
// 默认「Tokens」,且不再有第二条「成本(右轴)」图例
expect(legend).toHaveTextContent("usage.trend.tokens");
expect(screen.queryByText(/usage.trend.costLine/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "usage.trend.cost" }));
@@ -49,10 +49,10 @@ describe("UsageTrendChart (single metric)", () => {
);
await user.click(
screen.getByRole("button", { name: "usage.trend.tokens" }),
screen.getByRole("button", { name: "usage.trend.requests" }),
);
expect(screen.getByTestId("usage-trend-legend")).toHaveTextContent(
"usage.trend.tokens",
"usage.trend.requestsLegend",
);
});
});