Files
CC-Switch/src/components/usage/RequestLogTable.tsx
T
Dex Miller de23216e49 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>
2026-04-16 17:00:28 +08:00

471 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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,
getLocaleFromLanguage,
parseFiniteNumber,
} from "./format";
interface RequestLogTableProps {
range: UsageRangeSelection;
rangeLabel: string;
appType?: string;
refreshIntervalMs: number;
onRangeChange?: (range: UsageRangeSelection) => void;
}
export function RequestLogTable({
range,
rangeLabel,
appType: dashboardAppType,
refreshIntervalMs,
onRangeChange,
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
const [page, setPage] = useState(0);
const [pageInput, setPageInput] = useState("");
const pageSize = 20;
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
const effectiveFilters: LogFilters = dashboardAppTypeActive
? { ...appliedFilters, appType: dashboardAppType }
: appliedFilters;
const { data: result, isLoading } = useRequestLogs({
filters: effectiveFilters,
range,
page,
pageSize,
options: {
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
},
});
const logs = result?.data ?? [];
const total = result?.total ?? 0;
const totalPages = Math.ceil(total / pageSize);
useEffect(() => {
setPage(0);
}, [
dashboardAppType,
range.customEndDate,
range.customStartDate,
range.preset,
]);
const handleSearch = () => {
setAppliedFilters(draftFilters);
setPage(0);
};
const handleReset = () => {
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;
const parsed = Number(trimmed);
if (parsed < 1 || parsed > totalPages) return;
setPage(parsed - 1);
setPageInput("");
};
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
return (
<div className="space-y-4">
<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
? dashboardAppType
: draftFilters.appType || "all"
}
onValueChange={(v) =>
applySelectFilter("appType", v === "all" ? undefined : v)
}
disabled={!!dashboardAppTypeActive}
>
<SelectTrigger className="h-8 w-[110px] bg-background text-xs">
<SelectValue placeholder={t("usage.appType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
<SelectItem value="claude">Claude</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
</SelectContent>
</Select>
{/* Status code */}
<Select
value={draftFilters.statusCode?.toString() || "all"}
onValueChange={(v) =>
applySelectFilter(
"statusCode",
v === "all"
? undefined
: Number.isFinite(Number.parseInt(v, 10))
? Number.parseInt(v, 10)
: undefined,
)
}
>
<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</SelectItem>
<SelectItem value="401">401</SelectItem>
<SelectItem value="429">429</SelectItem>
<SelectItem value="500">500</SelectItem>
</SelectContent>
</Select>
{/* 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="h-8 bg-background text-xs"
value={draftFilters.model || ""}
onChange={(e) =>
setDraftFilters({
...draftFilters,
model: e.target.value || undefined,
})
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
/>
</div>
{onRangeChange && (
<UsageDateRangePicker
selection={range}
triggerLabel={rangeLabel}
onApply={onRangeChange}
/>
)}
{/* 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 ? (
<div className="h-[400px] animate-pulse rounded bg-gray-100" />
) : (
<>
<div className="rounded-lg border border-border/50 bg-card/40 backdrop-blur-sm overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center whitespace-nowrap">
{t("usage.time")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("usage.provider")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("usage.billingModel")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("usage.inputTokens")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("usage.outputTokens")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("usage.totalCost")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("usage.timingInfo")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("usage.status")}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("usage.source", { defaultValue: "Source" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 ? (
<TableRow>
<TableCell
colSpan={9}
className="text-center text-muted-foreground"
>
{t("usage.noData")}
</TableCell>
</TableRow>
) : (
logs.map((log) => (
<TableRow key={log.requestId}>
<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 className="text-center">
{log.providerName || t("usage.unknownProvider")}
</TableCell>
<TableCell className="text-center font-mono text-xs max-w-[200px]">
<div
className="truncate"
title={
log.requestModel && log.requestModel !== log.model
? `${log.requestModel}${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>
</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-center">
{fmtInt(log.outputTokens, locale)}
</TableCell>
<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-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>
)}
</TableCell>
<TableCell className="text-center">
<span
className={
log.statusCode >= 200 && log.statusCode < 300
? "text-green-600"
: "text-red-600"
}
>
{log.statusCode}
</span>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">
{log.dataSource || "proxy"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<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
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>
</div>
</div>
</>
)}
</div>
);
}