feat(usage): refine usage dashboard UI and date range picker (#2002)

* feat(usage): enhance usage stats backend and query hooks

* feat(usage): redesign calendar date range picker with auto-switch and simplified layout

* refactor(usage): streamline dashboard layout and stats components

* refactor(usage): compact request log table with merged cache/multiplier columns and centered layout

* feat(i18n): add cache short labels and usage stats translations for zh/en/ja

* Align usage dashboard stats with range boundaries

The usage dashboard mixed second-precision detail rows with day-level rollups, which caused custom half-day ranges to overcount historical rollup data and left the request log paginator on stale pages after top-level filter changes.

This change limits rollups to fully covered local days, aligns multi-day trend buckets with natural local days, and resets request log pagination when the dashboard range or app filter changes.

Constraint: usage_daily_rollups stores only daily aggregates after pruning old detail rows
Rejected: Include partial boundary rollups proportionally | historical intra-day detail is unavailable after pruning
Rejected: Force RequestLogTable remount on range change | would discard local draft filters unnecessarily
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep summary, trends, provider stats, and model stats on the same rollup-boundary rules
Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats
Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx
Tested: pnpm typecheck
Not-tested: Manual UI validation in the Tauri app

* Preserve full-day usage filters at minute precision

The latest review surfaced two interaction bugs in the usage dashboard: rollup-backed stats undercounted end days selected via the minute-precision picker, and immediate select changes accidentally applied unsubmitted text drafts from the request log filters.

This change treats 23:59 as a fully selected local end day for rollup inclusion and narrows select-side state syncing so app/status updates do not commit provider/model drafts.

Constraint: The custom range picker emits minute-precision timestamps, while rollups are stored at day granularity
Rejected: Require exact 23:59:59 end timestamps | unreachable from the current picker UI
Rejected: Rebuild applied filters from the full draft state on select changes | silently commits unsaved text input
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep request-log text fields on explicit apply semantics even when select filters remain immediate
Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats
Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx
Tested: pnpm typecheck
Not-tested: Manual Tauri dashboard interaction

* refactor(usage): move range presets into date picker, single-row layout

- UsageDateRangePicker: add preset shortcuts (今天/1d/7d/14d/30d) inside
  popover top; clicking a preset applies immediately and closes popover
- UsageDashboard: collapse to single row (app filters + refresh + picker);
  remove standalone preset buttons and summary stats bar
- RequestLogTable: replace static Calendar badge with interactive
  UsageDateRangePicker via onRangeChange prop; single filter row

* Keep usage pagination regression coverage aligned with the rendered UI

The new regression test was asserting a non-existent pagination label and page summary text, so it failed before it could verify the real page-reset behavior. This commit switches the assertions to the numbered pagination buttons that the component actually renders and validates the reset through the query hook arguments.

Constraint: RequestLogTable exposes numbered pagination buttons, not a "Next page" label or "2 / 6" summary text
Rejected: Add synthetic pagination labels solely for the test | would couple production markup to a test-only assumption
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Prefer pagination assertions that follow the rendered controls or hook inputs instead of invented summary text
Tested: pnpm vitest run tests/components/RequestLogTable.test.tsx; pnpm typecheck; pnpm test:unit

* refactor(usage): clean up dead code and polish date range picker

- Remove unused exports MAX_CUSTOM_USAGE_RANGE_SECONDS,
  timestampToLocalDatetime, and localDatetimeToTimestamp from
  usageRange.ts (replaced by the calendar picker)
- Deduplicate getPresetLabel from UsageDashboard and
  UsageDateRangePicker into shared getUsageRangePresetLabel helper
- Add aria-label, aria-current and aria-pressed to calendar day
  buttons so screen readers can disambiguate same-numbered days
  across adjacent months
- Drop unused cacheReadShort and cacheWriteShort i18n keys (zh/en/ja);
  the request log table renders R/W prefixes inline
- Align customRangeHint copy with the removed 30-day limit by
  dropping "up to 30 days" wording (zh/en/ja)

* fix(usage): align rollup cutoff to local midnight to keep days complete

`rollup_and_prune` previously used `Utc::now() - retain_days * 86400`
as the cutoff. Because rollups are bucketed by *local* date and detail
rows below the cutoff are pruned, an unaligned cutoff left the youngest
rolled-up day half-rolled-up and half-pruned. Combined with the new
`compute_rollup_date_bounds` boundary trimming (which excludes any
rollup day not fully covered by the requested range), custom range
queries that touch that day silently under-count summary, trend,
provider, and model stats.

Fix the invariant at the source: snap the cutoff to the next local
midnight after `(now - retain_days)`. Every rollup row now reflects a
complete local day, so the boundary trimmer's all-or-nothing assumption
holds.

Includes unit tests for the cutoff math (typical case + already-on-
midnight case). DST gap is handled defensively by bumping forward by
an hour.

Addresses Codex P2 review finding on PR #2002.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Dex Miller
2026-04-16 17:00:28 +08:00
committed by GitHub
parent 507bf038a9
commit de23216e49
18 changed files with 2156 additions and 766 deletions
+252 -442
View File
@@ -17,10 +17,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useRequestLogs, usageKeys } from "@/lib/query/usage";
import { useQueryClient } from "@tanstack/react-query";
import type { LogFilters } from "@/types/usage";
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
import { useRequestLogs } from "@/lib/query/usage";
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import {
fmtInt,
fmtUsd,
@@ -29,53 +29,28 @@ import {
} from "./format";
interface RequestLogTableProps {
range: UsageRangeSelection;
rangeLabel: string;
appType?: string;
refreshIntervalMs: number;
timeRange?: "1d" | "7d" | "30d";
onRangeChange?: (range: UsageRangeSelection) => void;
}
const ONE_DAY_SECONDS = 24 * 60 * 60;
const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
const TIME_RANGE_SECONDS: Record<string, number> = {
"1d": ONE_DAY_SECONDS,
"7d": 7 * ONE_DAY_SECONDS,
"30d": 30 * ONE_DAY_SECONDS,
};
type TimeMode = "rolling" | "fixed";
export function RequestLogTable({
range,
rangeLabel,
appType: dashboardAppType,
refreshIntervalMs,
timeRange = "1d",
onRangeChange,
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const rollingWindowSeconds = TIME_RANGE_SECONDS[timeRange] ?? ONE_DAY_SECONDS;
const getRollingRange = () => {
const now = Math.floor(Date.now() / 1000);
return { startDate: now - rollingWindowSeconds, endDate: now };
};
const [appliedTimeMode, setAppliedTimeMode] = useState<TimeMode>("rolling");
const [draftTimeMode, setDraftTimeMode] = useState<TimeMode>("rolling");
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
const [page, setPage] = useState(0);
const [pageInput, setPageInput] = useState("");
const pageSize = 20;
const [validationError, setValidationError] = useState<string | null>(null);
// Reset page when the dashboard time range changes
useEffect(() => {
setPage(0);
}, [timeRange]);
// When dashboard-level app filter is active (not "all"), override the local appType filter
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
const effectiveFilters: LogFilters = dashboardAppTypeActive
? { ...appliedFilters, appType: dashboardAppType }
@@ -83,8 +58,7 @@ export function RequestLogTable({
const { data: result, isLoading } = useRequestLogs({
filters: effectiveFilters,
timeMode: appliedTimeMode,
rollingWindowSeconds,
range,
page,
pageSize,
options: {
@@ -96,56 +70,41 @@ export function RequestLogTable({
const total = result?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
useEffect(() => {
setPage(0);
}, [
dashboardAppType,
range.customEndDate,
range.customStartDate,
range.preset,
]);
const handleSearch = () => {
setValidationError(null);
if (draftTimeMode === "fixed") {
const start = draftFilters.startDate;
const end = draftFilters.endDate;
if (typeof start !== "number" || typeof end !== "number") {
setValidationError(
t("usage.invalidTimeRange", "请选择完整的开始/结束时间"),
);
return;
}
if (start > end) {
setValidationError(
t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"),
);
return;
}
if (end - start > MAX_FIXED_RANGE_SECONDS) {
setValidationError(
t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"),
);
return;
}
}
setAppliedTimeMode(draftTimeMode);
setAppliedFilters((prev) => {
const next = { ...prev, ...draftFilters };
if (draftTimeMode === "rolling") {
delete next.startDate;
delete next.endDate;
}
return next;
});
setAppliedFilters(draftFilters);
setPage(0);
};
const handleReset = () => {
setValidationError(null);
setAppliedTimeMode("rolling");
setDraftTimeMode("rolling");
setDraftFilters({});
setAppliedFilters({});
setPage(0);
};
const applySelectFilter = <K extends keyof LogFilters>(
key: K,
value: LogFilters[K],
) => {
setDraftFilters((prev) => ({
...prev,
[key]: value,
}));
setAppliedFilters((prev) => ({
...prev,
[key]: value,
}));
setPage(0);
};
const handleGoToPage = () => {
const trimmed = pageInput.trim();
if (!/^\d+$/.test(trimmed)) return;
@@ -155,58 +114,14 @@ export function RequestLogTable({
setPageInput("");
};
const handleRefresh = () => {
const key = {
timeMode: appliedTimeMode,
rollingWindowSeconds:
appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined,
appType: appliedFilters.appType,
providerName: appliedFilters.providerName,
model: appliedFilters.model,
statusCode: appliedFilters.statusCode,
startDate:
appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined,
endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined,
};
queryClient.invalidateQueries({
queryKey: usageKeys.logs(key, page, pageSize),
});
};
// 将 Unix 时间戳转换为本地时间的 datetime-local 格式
const timestampToLocalDatetime = (timestamp: number): string => {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
// 将 datetime-local 格式转换为 Unix 时间戳
const localDatetimeToTimestamp = (datetime: string): number | undefined => {
if (!datetime) return undefined;
// 验证格式是否完整 (YYYY-MM-DDTHH:mm)
if (datetime.length < 16) return undefined;
const timestamp = new Date(datetime).getTime();
// 验证是否为有效日期
if (isNaN(timestamp)) return undefined;
return Math.floor(timestamp / 1000);
};
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
const rollingRangeForDisplay =
draftTimeMode === "rolling" ? getRollingRange() : null;
return (
<div className="space-y-4">
{/* 筛选栏 */}
<div className="flex flex-col gap-4 rounded-lg border bg-card/50 p-4 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-3">
<div className="rounded-lg border bg-card/50 p-2 backdrop-blur-sm">
<div className="flex flex-wrap items-center gap-1.5">
{/* App type */}
<Select
value={
dashboardAppTypeActive
@@ -214,14 +129,11 @@ export function RequestLogTable({
: draftFilters.appType || "all"
}
onValueChange={(v) =>
setDraftFilters({
...draftFilters,
appType: v === "all" ? undefined : v,
})
applySelectFilter("appType", v === "all" ? undefined : v)
}
disabled={!!dashboardAppTypeActive}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectTrigger className="h-8 w-[110px] bg-background text-xs">
<SelectValue placeholder={t("usage.appType")} />
</SelectTrigger>
<SelectContent>
@@ -232,51 +144,57 @@ export function RequestLogTable({
</SelectContent>
</Select>
{/* Status code */}
<Select
value={draftFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
setDraftFilters({
...draftFilters,
statusCode:
v === "all"
? undefined
: Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10)
: undefined,
})
applySelectFilter(
"statusCode",
v === "all"
? undefined
: Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10)
: undefined,
)
}
>
<SelectTrigger className="w-[130px] bg-background">
<SelectTrigger className="h-8 w-[100px] bg-background text-xs">
<SelectValue placeholder={t("usage.statusCode")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("common.all")}</SelectItem>
<SelectItem value="200">200 OK</SelectItem>
<SelectItem value="400">400 Bad Request</SelectItem>
<SelectItem value="401">401 Unauthorized</SelectItem>
<SelectItem value="429">429 Rate Limit</SelectItem>
<SelectItem value="500">500 Server Error</SelectItem>
<SelectItem value="400">400</SelectItem>
<SelectItem value="401">401</SelectItem>
<SelectItem value="429">429</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("usage.searchProviderPlaceholder")}
className="pl-9 bg-background"
value={draftFilters.providerName || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
providerName: e.target.value || undefined,
})
}
/>
</div>
{/* Provider search */}
<div className="relative min-w-[140px] flex-1">
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("usage.searchProviderPlaceholder")}
className="h-8 bg-background pl-7 text-xs"
value={draftFilters.providerName || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
providerName: e.target.value || undefined,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
{/* Model search */}
<div className="relative min-w-[120px] flex-1">
<Input
placeholder={t("usage.searchModelPlaceholder")}
className="w-[180px] bg-background"
className="h-8 bg-background text-xs"
value={draftFilters.model || ""}
onChange={(e) =>
setDraftFilters({
@@ -284,89 +202,40 @@ export function RequestLogTable({
model: e.target.value || undefined,
})
}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="whitespace-nowrap">{t("usage.timeRange")}:</span>
<Input
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
(rollingRangeForDisplay?.startDate ?? draftFilters.startDate)
? timestampToLocalDatetime(
(rollingRangeForDisplay?.startDate ??
draftFilters.startDate) as number,
)
: ""
}
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setDraftTimeMode("fixed");
setDraftFilters({
...draftFilters,
startDate: timestamp,
});
}}
/>
<span>-</span>
<Input
type="datetime-local"
className="h-8 w-[200px] bg-background"
value={
(rollingRangeForDisplay?.endDate ?? draftFilters.endDate)
? timestampToLocalDatetime(
(rollingRangeForDisplay?.endDate ??
draftFilters.endDate) as number,
)
: ""
}
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setDraftTimeMode("fixed");
setDraftFilters({
...draftFilters,
endDate: timestamp,
});
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
<div className="flex items-center gap-2 ml-auto">
<Button
size="sm"
variant="default"
onClick={handleSearch}
className="h-8"
>
<Search className="mr-2 h-3.5 w-3.5" />
{t("common.search")}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleReset}
className="h-8"
>
<X className="mr-2 h-3.5 w-3.5" />
{t("common.reset")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleRefresh}
className="h-8 px-2"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
{onRangeChange && (
<UsageDateRangePicker
selection={range}
triggerLabel={rangeLabel}
onApply={onRangeChange}
/>
)}
{validationError && (
<div className="text-sm text-red-600">{validationError}</div>
)}
{/* Search & Reset (icon-only) */}
<Button
size="icon"
variant="default"
onClick={handleSearch}
className="h-8 w-8"
title={t("common.search")}
>
<Search className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="outline"
onClick={handleReset}
className="h-8 w-8"
title={t("common.reset")}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{isLoading ? (
@@ -377,40 +246,31 @@ export function RequestLogTable({
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">
<TableHead className="text-center whitespace-nowrap">
{t("usage.time")}
</TableHead>
<TableHead className="whitespace-nowrap">
<TableHead className="text-center whitespace-nowrap">
{t("usage.provider")}
</TableHead>
<TableHead className="min-w-[200px] whitespace-nowrap">
<TableHead className="text-center whitespace-nowrap">
{t("usage.billingModel")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
<TableHead className="text-center whitespace-nowrap">
{t("usage.inputTokens")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
<TableHead className="text-center whitespace-nowrap">
{t("usage.outputTokens")}
</TableHead>
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheReadTokens")}
</TableHead>
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
{t("usage.cacheCreationTokens")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("usage.multiplier")}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
<TableHead className="text-center whitespace-nowrap">
{t("usage.totalCost")}
</TableHead>
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
<TableHead className="text-center whitespace-nowrap">
{t("usage.timingInfo")}
</TableHead>
<TableHead className="whitespace-nowrap">
<TableHead className="text-center whitespace-nowrap">
{t("usage.status")}
</TableHead>
<TableHead className="whitespace-nowrap">
<TableHead className="text-center whitespace-nowrap">
{t("usage.source", { defaultValue: "Source" })}
</TableHead>
</TableRow>
@@ -419,7 +279,7 @@ export function RequestLogTable({
{logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={12}
colSpan={9}
className="text-center text-muted-foreground"
>
{t("usage.noData")}
@@ -428,140 +288,96 @@ export function RequestLogTable({
) : (
logs.map((log) => (
<TableRow key={log.requestId}>
<TableCell>
{new Date(log.createdAt * 1000).toLocaleString(locale)}
<TableCell className="text-center whitespace-nowrap text-xs px-1.5">
{new Date(log.createdAt * 1000).toLocaleString(locale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell>
<TableCell className="text-center">
{log.providerName || t("usage.unknownProvider")}
</TableCell>
<TableCell className="font-mono text-xs max-w-[200px]">
<TableCell className="text-center font-mono text-xs max-w-[200px]">
<div
className="truncate"
title={
log.requestModel && log.requestModel !== log.model
? `${t("usage.requestModel")}: ${log.requestModel}\n${t("usage.responseModel")}: ${log.model}`
? `${log.requestModel} ${log.model}`
: log.model
}
>
{log.model}
{log.requestModel &&
log.requestModel !== log.model ? (
<span>
{log.requestModel}
<span className="text-muted-foreground">
{" → "}
{log.model}
</span>
</span>
) : (
log.model
)}
</div>
{log.requestModel && log.requestModel !== log.model && (
<div
className="truncate text-muted-foreground text-[10px]"
title={log.requestModel}
>
{log.requestModel}
</TableCell>
<TableCell className="text-center px-1.5">
<div className="tabular-nums">
{fmtInt(log.inputTokens, locale)}
</div>
{(log.cacheReadTokens > 0 ||
log.cacheCreationTokens > 0) && (
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
{[
log.cacheReadTokens > 0 &&
`R${fmtInt(log.cacheReadTokens, locale)}`,
log.cacheCreationTokens > 0 &&
`W${fmtInt(log.cacheCreationTokens, locale)}`,
]
.filter(Boolean)
.join("·")}
</div>
)}
</TableCell>
<TableCell className="text-right">
{fmtInt(log.inputTokens, locale)}
</TableCell>
<TableCell className="text-right">
<TableCell className="text-center">
{fmtInt(log.outputTokens, locale)}
</TableCell>
<TableCell className="text-right">
{fmtInt(log.cacheReadTokens, locale)}
<TableCell className="text-center px-1.5">
<div className="font-medium tabular-nums">
{fmtUsd(log.totalCostUsd, 4)}
</div>
{parseFiniteNumber(log.costMultiplier) != null &&
parseFiniteNumber(log.costMultiplier) !== 1 && (
<div className="text-[11px] text-muted-foreground">
×
{parseFiniteNumber(log.costMultiplier)?.toFixed(
2,
)}
</div>
)}
</TableCell>
<TableCell className="text-right">
{fmtInt(log.cacheCreationTokens, locale)}
</TableCell>
<TableCell className="text-right font-mono text-xs">
{(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? (
<span className="text-orange-600">
×{log.costMultiplier}
<TableCell className="text-center whitespace-nowrap text-xs tabular-nums">
{(log.latencyMs / 1000).toFixed(1)}s
{log.firstTokenMs != null && (
<span className="text-muted-foreground">
/{(log.firstTokenMs / 1000).toFixed(1)}s
</span>
) : (
<span className="text-muted-foreground">×1</span>
)}
</TableCell>
<TableCell className="text-right">
{fmtUsd(log.totalCostUsd, 6)}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
{(() => {
const durationMs =
typeof log.durationMs === "number"
? log.durationMs
: log.latencyMs;
const durationSec = durationMs / 1000;
const durationColor = Number.isFinite(durationSec)
? durationSec <= 5
? "bg-green-100 text-green-800"
: durationSec <= 120
? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900"
: "bg-gray-100 text-gray-700";
return (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${durationColor}`}
>
{Number.isFinite(durationSec)
? `${Math.round(durationSec)}s`
: "--"}
</span>
);
})()}
{log.isStreaming &&
log.firstTokenMs != null &&
(() => {
const firstSec = log.firstTokenMs / 1000;
const firstColor = Number.isFinite(firstSec)
? firstSec <= 5
? "bg-green-100 text-green-800"
: firstSec <= 120
? "bg-orange-100 text-orange-800"
: "bg-red-200 text-red-900"
: "bg-gray-100 text-gray-700";
return (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${firstColor}`}
>
{Number.isFinite(firstSec)
? `${firstSec.toFixed(1)}s`
: "--"}
</span>
);
})()}
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${
log.isStreaming
? "bg-blue-100 text-blue-800"
: "bg-purple-100 text-purple-800"
}`}
>
{log.isStreaming
? t("usage.stream")
: t("usage.nonStream")}
</span>
</div>
</TableCell>
<TableCell>
<TableCell className="text-center">
<span
className={`inline-flex rounded-full px-2 py-1 text-xs ${
className={
log.statusCode >= 200 && log.statusCode < 300
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
? "text-green-600"
: "text-red-600"
}
>
{log.statusCode}
</span>
</TableCell>
<TableCell>
{log.dataSource && log.dataSource !== "proxy" ? (
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-indigo-100 text-indigo-800">
{t(`usage.dataSource.${log.dataSource}`, {
defaultValue: log.dataSource,
})}
</span>
) : (
<span className="inline-flex rounded-full px-2 py-0.5 text-[10px] bg-gray-100 text-gray-600">
{t("usage.dataSource.proxy", {
defaultValue: "Proxy",
})}
</span>
)}
<TableCell className="text-center text-xs text-muted-foreground">
{log.dataSource || "proxy"}
</TableCell>
</TableRow>
))
@@ -570,89 +386,83 @@ export function RequestLogTable({
</Table>
</div>
{/* 分页控件 */}
{total > 0 && (
<div className="flex items-center justify-between px-2">
<span className="text-sm text-muted-foreground">
{t("usage.totalRecords", { total })}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{(() => {
const pages: (number | string)[] = [];
// 3 head + 3 tail + 3 neighborhood = 9 max distinct pages
if (totalPages <= 9) {
for (let i = 0; i < totalPages; i++) pages.push(i);
} else {
const pageSet = new Set<number>();
for (let i = 0; i < 3; i++) pageSet.add(i);
for (let i = totalPages - 3; i < totalPages; i++)
pageSet.add(i);
for (
let i = Math.max(0, page - 1);
i <= Math.min(totalPages - 1, page + 1);
i++
)
pageSet.add(i);
const sorted = Array.from(pageSet).sort((a, b) => a - b);
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
pages.push(`ellipsis-${i}`);
}
pages.push(sorted[i]);
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{t("usage.totalRecords", { total })}</span>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
{(() => {
const pages: (number | string)[] = [];
if (totalPages <= 9) {
for (let i = 0; i < totalPages; i++) pages.push(i);
} else {
const pageSet = new Set<number>();
for (let i = 0; i < 3; i++) pageSet.add(i);
for (let i = totalPages - 3; i < totalPages; i++)
pageSet.add(i);
for (
let i = Math.max(0, page - 1);
i <= Math.min(totalPages - 1, page + 1);
i++
)
pageSet.add(i);
const sorted = Array.from(pageSet).sort((a, b) => a - b);
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
pages.push(`ellipsis-${i}`);
}
pages.push(sorted[i]);
}
return pages.map((p) =>
typeof p === "string" ? (
<span key={p} className="px-2 text-muted-foreground">
...
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => setPage(p)}
>
{p + 1}
</Button>
),
);
})()}
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
>
<ChevronRight className="h-4 w-4" />
}
return pages.map((p) =>
typeof p === "string" ? (
<span key={p} className="px-2 text-muted-foreground">
...
</span>
) : (
<Button
key={p}
variant={p === page ? "default" : "outline"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => setPage(p)}
>
{p + 1}
</Button>
),
);
})()}
<Button
size="sm"
variant="outline"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<ChevronRight className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 ml-2">
<Input
type="text"
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleGoToPage();
}}
placeholder={t("usage.pageInputPlaceholder")}
className="h-8 w-16 text-center text-xs"
/>
<Button variant="outline" size="sm" onClick={handleGoToPage}>
{t("usage.goToPage")}
</Button>
<div className="flex items-center gap-1 ml-2">
<Input
type="text"
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleGoToPage();
}}
placeholder={t("usage.pageInputPlaceholder")}
className="h-8 w-16 text-center text-xs"
/>
<Button variant="outline" size="sm" onClick={handleGoToPage}>
{t("usage.goToPage")}
</Button>
</div>
</div>
</div>
)}
</div>
</>
)}
</div>