feat(usage): paginate the request log, provider, model and pricing tables

Extract the request-log pager into a shared TablePagination and add useClientPagination for tables that load all rows at once. Pages hold 20 rows, reset when the range or filters change, and clamp when a refresh returns fewer rows.

Reapplies c932436dc, reverted in 6d077f2a1.
This commit is contained in:
Owlbay authored and Jason committed 2026-10-04 12:58:31 +08:00
1 parent c42feedd30
commit ebd9357ceb
6 files changed
+404 -206

No files matched your search

+68 -55
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useModelStats } from "@/lib/query/usage";
import { TablePagination, useClientPagination } from "./TablePagination";
import { cn } from "@/lib/utils";
import {
fmtInt,
@@ -41,70 +42,82 @@ export function ModelStatsTable({
() => [...(stats ?? [])].sort((a, b) => b.requestCount - a.requestCount),
[stats],
);
const pagination = useClientPagination(
rows,
JSON.stringify([range, appType, providerName, model]),
);
if (isLoading) {
return <div className={usageTable.skeleton} />;
}
return (
<div className={usageTable.scroller}>
<table
className={cn(usageTable.table, "min-w-[560px]")}
aria-label={t("usage.modelStats")}
>
<thead>
<tr className={usageTable.headRow}>
<th className={usageTable.th}>{t("usage.model")}</th>
<th className={usageTable.thEnd}>{t("usage.requests")}</th>
<th className={usageTable.thEnd}>{t("usage.tokens")}</th>
<th className={usageTable.thEnd}>{t("usage.totalCost")}</th>
<th className={usageTable.thEnd}>{t("usage.avgCost")}</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className={usageTable.empty}>
{t("usage.noData")}
</td>
<div className="flex flex-col">
<div className={usageTable.scroller}>
<table
className={cn(usageTable.table, "min-w-[560px]")}
aria-label={t("usage.modelStats")}
>
<thead>
<tr className={usageTable.headRow}>
<th className={usageTable.th}>{t("usage.model")}</th>
<th className={usageTable.thEnd}>{t("usage.requests")}</th>
<th className={usageTable.thEnd}>{t("usage.tokens")}</th>
<th className={usageTable.thEnd}>{t("usage.totalCost")}</th>
<th className={usageTable.thEnd}>{t("usage.avgCost")}</th>
</tr>
) : (
rows.map((stat) => (
<tr key={stat.model} className={usageTable.row}>
<td className={cn(usageTable.td, usageTable.mono)}>
<span
className="block max-w-[320px] truncate"
title={stat.model}
>
{stat.model}
</span>
</td>
<td className={usageTable.tdEnd}>
{fmtInt(stat.requestCount, locale)}
</td>
<td
className={usageTable.tdEnd}
title={fmtInt(stat.totalTokens, locale)}
>
{formatTokensCompact(stat.totalTokens, locale)}
</td>
<td
className={cn(usageTable.tdEnd, "font-medium")}
title={fmtUsd(stat.totalCost, 6)}
>
{fmtUsd(stat.totalCost, 2)}
</td>
<td
className={cn(usageTable.tdEnd, "text-fg-2")}
title={fmtUsd(stat.avgCostPerRequest, 6)}
>
{fmtUsd(stat.avgCostPerRequest, 4)}
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className={usageTable.empty}>
{t("usage.noData")}
</td>
</tr>
))
)}
</tbody>
</table>
) : (
pagination.pageRows.map((stat) => (
<tr key={stat.model} className={usageTable.row}>
<td className={cn(usageTable.td, usageTable.mono)}>
<span
className="block max-w-[320px] truncate"
title={stat.model}
>
{stat.model}
</span>
</td>
<td className={usageTable.tdEnd}>
{fmtInt(stat.requestCount, locale)}
</td>
<td
className={usageTable.tdEnd}
title={fmtInt(stat.totalTokens, locale)}
>
{formatTokensCompact(stat.totalTokens, locale)}
</td>
<td
className={cn(usageTable.tdEnd, "font-medium")}
title={fmtUsd(stat.totalCost, 6)}
>
{fmtUsd(stat.totalCost, 2)}
</td>
<td
className={cn(usageTable.tdEnd, "text-fg-2")}
title={fmtUsd(stat.avgCostPerRequest, 6)}
>
{fmtUsd(stat.avgCostPerRequest, 4)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<TablePagination
page={pagination.page}
totalPages={pagination.totalPages}
total={pagination.total}
onPageChange={pagination.setPage}
/>
</div>
);
}
+11 -1
View File
@@ -24,6 +24,7 @@ import {
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { AppGlyph, APP_DISPLAY_NAME } from "@/components/shell/AppGlyph";
import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage";
import { TablePagination, useClientPagination } from "./TablePagination";
import { proxyApi } from "@/lib/api/proxy";
import type { ModelPricing } from "@/types/usage";
import { cn } from "@/lib/utils";
@@ -329,6 +330,7 @@ export function PricingConfigPanel() {
};
const rows = pricing ?? [];
const pagination = useClientPagination(rows);
return (
<div className="flex flex-col gap-3 pt-3">
@@ -403,7 +405,7 @@ export function PricingConfigPanel() {
</td>
</tr>
) : (
rows.map((model) => {
pagination.pageRows.map((model) => {
const name = model.displayName || model.modelId;
return (
<tr
@@ -479,6 +481,14 @@ export function PricingConfigPanel() {
</table>
</div>
)}
{!isLoading && !error && (
<TablePagination
page={pagination.page}
totalPages={pagination.totalPages}
total={pagination.total}
onPageChange={pagination.setPage}
/>
)}
{editingModel && (
<PricingEditModal
+104 -91
View File
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useProviderStats } from "@/lib/query/usage";
import { TablePagination, useClientPagination } from "./TablePagination";
import { HelpTip } from "@/components/ui/help-tip";
import { cn } from "@/lib/utils";
import {
@@ -65,105 +66,117 @@ export function ProviderStatsTable({
() => [...(stats ?? [])].sort((a, b) => b.requestCount - a.requestCount),
[stats],
);
const pagination = useClientPagination(
rows,
JSON.stringify([range, appType, providerName, model]),
);
if (isLoading) {
return <div className={usageTable.skeleton} />;
}
return (
<div className={usageTable.scroller}>
<table
className={cn(usageTable.table, "min-w-[620px]")}
aria-label={t("usage.providerStats")}
>
<thead>
<tr className={usageTable.headRow}>
<th className={usageTable.th}>{t("usage.provider")}</th>
<th className={usageTable.thEnd}>{t("usage.requests")}</th>
<th className={usageTable.thEnd}>{t("usage.tokens")}</th>
<th className={usageTable.thEnd}>{t("usage.cost")}</th>
<th className={usageTable.thEnd}>{t("usage.successRate")}</th>
<th className={usageTable.thEnd}>
<span className="inline-flex items-center gap-0.5">
{t("usage.speed")}
<HelpTip title={t("usage.speedSumHelpTitle")} align="end">
{t("usage.speedSumHelp")}
</HelpTip>
</span>
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={6} className={usageTable.empty}>
{t("usage.noData")}
</td>
<div className="flex flex-col">
<div className={usageTable.scroller}>
<table
className={cn(usageTable.table, "min-w-[620px]")}
aria-label={t("usage.providerStats")}
>
<thead>
<tr className={usageTable.headRow}>
<th className={usageTable.th}>{t("usage.provider")}</th>
<th className={usageTable.thEnd}>{t("usage.requests")}</th>
<th className={usageTable.thEnd}>{t("usage.tokens")}</th>
<th className={usageTable.thEnd}>{t("usage.cost")}</th>
<th className={usageTable.thEnd}>{t("usage.successRate")}</th>
<th className={usageTable.thEnd}>
<span className="inline-flex items-center gap-0.5">
{t("usage.speed")}
<HelpTip title={t("usage.speedSumHelpTitle")} align="end">
{t("usage.speedSumHelp")}
</HelpTip>
</span>
</th>
</tr>
) : (
rows.map((stat) => {
const exactSpeed = getProviderSpeed(stat);
const estimatedSpeed =
exactSpeed == null ? getProviderEstimatedSpeed(stat) : null;
const speed = exactSpeed ?? estimatedSpeed;
return (
<tr
key={`${stat.providerId}:${stat.providerName}`}
className={usageTable.row}
>
<td className={usageTable.td}>
<span
className="block max-w-[260px] truncate"
title={stat.providerName}
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={6} className={usageTable.empty}>
{t("usage.noData")}
</td>
</tr>
) : (
pagination.pageRows.map((stat) => {
const exactSpeed = getProviderSpeed(stat);
const estimatedSpeed =
exactSpeed == null ? getProviderEstimatedSpeed(stat) : null;
const speed = exactSpeed ?? estimatedSpeed;
return (
<tr
key={`${stat.providerId}:${stat.providerName}`}
className={usageTable.row}
>
<td className={usageTable.td}>
<span
className="block max-w-[260px] truncate"
title={stat.providerName}
>
{stat.providerName}
</span>
</td>
<td className={usageTable.tdEnd}>
{fmtInt(stat.requestCount, locale)}
</td>
<td
className={usageTable.tdEnd}
title={fmtInt(stat.totalTokens, locale)}
>
{stat.providerName}
</span>
</td>
<td className={usageTable.tdEnd}>
{fmtInt(stat.requestCount, locale)}
</td>
<td
className={usageTable.tdEnd}
title={fmtInt(stat.totalTokens, locale)}
>
{formatTokensCompact(stat.totalTokens, locale)}
</td>
<td
className={cn(usageTable.tdEnd, "font-medium")}
title={fmtUsd(stat.totalCost, 6)}
>
{fmtUsd(stat.totalCost, 2)}
</td>
<td className={usageTable.tdEnd}>
{stat.successRate.toFixed(
stat.successRate >= 99.95 ? 0 : 1,
)}
%
</td>
<td
className={cn(
usageTable.tdEnd,
speed == null && usageTable.muted,
)}
>
{speed == null ? (
"—"
) : (
<>
{estimatedSpeed != null && "≈"}
{speed}
<span className="ms-0.5 text-badge font-normal text-fg-3">
tok/s
</span>
</>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
{formatTokensCompact(stat.totalTokens, locale)}
</td>
<td
className={cn(usageTable.tdEnd, "font-medium")}
title={fmtUsd(stat.totalCost, 6)}
>
{fmtUsd(stat.totalCost, 2)}
</td>
<td className={usageTable.tdEnd}>
{stat.successRate.toFixed(
stat.successRate >= 99.95 ? 0 : 1,
)}
%
</td>
<td
className={cn(
usageTable.tdEnd,
speed == null && usageTable.muted,
)}
>
{speed == null ? (
"—"
) : (
<>
{estimatedSpeed != null && "≈"}
{speed}
<span className="ms-0.5 text-badge font-normal text-fg-3">
tok/s
</span>
</>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<TablePagination
page={pagination.page}
totalPages={pagination.totalPages}
total={pagination.total}
onPageChange={pagination.setPage}
/>
</div>
);
}
+7 -59
View File
@@ -1,9 +1,8 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRequestLogs } from "@/lib/query/usage";
import { TablePagination } from "./TablePagination";
import { HelpTip } from "@/components/ui/help-tip";
import { HoverTip } from "@/components/ui/hover-tip";
import { AppGlyph, APP_DISPLAY_NAME } from "@/components/shell/AppGlyph";
import type { AppId } from "@/lib/api";
import {
@@ -111,7 +110,6 @@ export function RequestLogTable({
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const [page, setPage] = useState(0);
const [pageDraft, setPageDraft] = useState<string | null>(null);
const pageSize = 20;
const effectiveFilters: LogFilters = {
@@ -140,7 +138,6 @@ export function RequestLogTable({
useEffect(() => {
setPage(0);
setPageDraft(null);
}, [
dashboardAppType,
providerName,
@@ -151,16 +148,6 @@ export function RequestLogTable({
range.preset,
]);
const commitPageDraft = () => {
if (pageDraft == null) return;
const trimmed = pageDraft.trim();
setPageDraft(null);
if (!/^\d+$/.test(trimmed)) return;
const parsed = Number(trimmed);
if (parsed < 1 || parsed > totalPages) return;
setPage(parsed - 1);
};
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
// 「今天」按本地时区判断;每次渲染(含自动刷新)取一次当前时间
@@ -364,51 +351,12 @@ export function RequestLogTable({
</table>
</div>
<div className="flex h-10 items-center gap-2 text-caption text-fg-3">
<span className="tabular-nums">
{t("usage.totalRecords", { total })}
</span>
<div className="flex-1" />
<HoverTip content={t("usage.prevPage")}>
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-control text-fg-2 transition-colors hover:bg-subtle hover:text-fg-1 disabled:pointer-events-none disabled:opacity-45"
aria-label={t("usage.prevPage")}
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft className="h-4 w-4" />
</button>
</HoverTip>
<span className="flex items-center gap-1 tabular-nums text-fg-2">
<input
type="text"
inputMode="numeric"
aria-label={t("usage.pageInputPlaceholder")}
className="h-6 w-9 rounded-[4px] border border-transparent bg-transparent text-center text-caption text-fg-1 transition-[border-color,box-shadow] hover:border-border-strong focus:border-ring focus:bg-surface focus:outline-none focus:ring-[3px] focus:ring-ring/20"
value={pageDraft ?? String(page + 1)}
onChange={(event) => setPageDraft(event.target.value)}
onFocus={(event) => event.target.select()}
onBlur={commitPageDraft}
onKeyDown={(event) => {
if (event.key === "Enter") commitPageDraft();
if (event.key === "Escape") setPageDraft(null);
}}
/>
<span>/ {fmtInt(totalPages, locale)}</span>
</span>
<HoverTip content={t("usage.nextPage")}>
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-control text-fg-2 transition-colors hover:bg-subtle hover:text-fg-1 disabled:pointer-events-none disabled:opacity-45"
aria-label={t("usage.nextPage")}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<ChevronRight className="h-4 w-4" />
</button>
</HoverTip>
</div>
<TablePagination
page={page}
totalPages={totalPages}
total={total}
onPageChange={setPage}
/>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { HoverTip } from "@/components/ui/hover-tip";
import { fmtInt, getLocaleFromLanguage } from "./format";
interface TablePaginationProps {
/** 从 0 开始的页码 */
page: number;
totalPages: number;
/** 总条数,显示在左侧 */
total: number;
onPageChange: (page: number) => void;
}
const NAV_BUTTON =
"inline-flex h-7 w-7 items-center justify-center rounded-control text-fg-2 transition-colors hover:bg-subtle hover:text-fg-1 disabled:pointer-events-none disabled:opacity-45";
/** 用量页表格共用的翻页条:总数、上一页、可输入的页码、下一页。 */
export function TablePagination({
page,
totalPages,
total,
onPageChange,
}: TablePaginationProps) {
const { t, i18n } = useTranslation();
const locale = getLocaleFromLanguage(
i18n.resolvedLanguage || i18n.language || "en",
);
const [pageDraft, setPageDraft] = useState<string | null>(null);
// 页码从外部变了(翻页、筛选重置)时丢掉没提交的输入
useEffect(() => {
setPageDraft(null);
}, [page]);
const commitPageDraft = () => {
if (pageDraft == null) return;
const trimmed = pageDraft.trim();
setPageDraft(null);
if (!/^\d+$/.test(trimmed)) return;
const parsed = Number(trimmed);
if (parsed < 1 || parsed > totalPages) return;
onPageChange(parsed - 1);
};
return (
<div className="flex h-10 items-center gap-2 text-caption text-fg-3">
<span className="tabular-nums">{t("usage.totalRecords", { total })}</span>
<div className="flex-1" />
<HoverTip content={t("usage.prevPage")}>
<button
type="button"
className={NAV_BUTTON}
aria-label={t("usage.prevPage")}
disabled={page === 0}
onClick={() => onPageChange(Math.max(0, page - 1))}
>
<ChevronLeft className="h-4 w-4" />
</button>
</HoverTip>
<span className="flex items-center gap-1 tabular-nums text-fg-2">
<input
type="text"
inputMode="numeric"
aria-label={t("usage.pageInputPlaceholder")}
className="h-6 w-9 rounded-[4px] border border-transparent bg-transparent text-center text-caption text-fg-1 transition-[border-color,box-shadow] hover:border-border-strong focus:border-ring focus:bg-surface focus:outline-none focus:ring-[3px] focus:ring-ring/20"
value={pageDraft ?? String(page + 1)}
onChange={(event) => setPageDraft(event.target.value)}
onFocus={(event) => event.target.select()}
onBlur={commitPageDraft}
onKeyDown={(event) => {
if (event.key === "Enter") commitPageDraft();
if (event.key === "Escape") setPageDraft(null);
}}
/>
<span>/ {fmtInt(totalPages, locale)}</span>
</span>
<HoverTip content={t("usage.nextPage")}>
<button
type="button"
className={NAV_BUTTON}
aria-label={t("usage.nextPage")}
disabled={page >= totalPages - 1}
onClick={() => onPageChange(Math.min(totalPages - 1, page + 1))}
>
<ChevronRight className="h-4 w-4" />
</button>
</HoverTip>
</div>
);
}
export const TABLE_PAGE_SIZE = 20;
/**
* 前端分页:数据一次性拿到、在客户端切页的表格(供应商、模型、定价)用。
* `resetKey` 变了(换时间范围 / 筛选)回到第一页;自动刷新只换数据不换 key,
* 停在当前页,数据变少时把页码收回到最后一页。
*/
export function useClientPagination<T>(
rows: readonly T[],
resetKey: string = "",
pageSize: number = TABLE_PAGE_SIZE,
) {
const [page, setPage] = useState(0);
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
useEffect(() => {
setPage(0);
}, [resetKey]);
const currentPage = Math.min(page, totalPages - 1);
const pageRows = rows.slice(
currentPage * pageSize,
(currentPage + 1) * pageSize,
);
return {
page: currentPage,
setPage,
totalPages,
total: rows.length,
pageRows,
};
}
+88
View File
@@ -0,0 +1,88 @@
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
TablePagination,
useClientPagination,
} from "@/components/usage/TablePagination";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) =>
options && "total" in options ? `${key}:${options.total}` : key,
i18n: { resolvedLanguage: "en", language: "en" },
}),
}));
function Harness({ count }: { count: number }) {
const [filter, setFilter] = useState("a");
const rows = Array.from({ length: count }, (_, i) => `row-${i + 1}`);
const pagination = useClientPagination(rows, filter);
return (
<div>
<button type="button" onClick={() => setFilter("b")}>
change-filter
</button>
<ul>
{pagination.pageRows.map((row) => (
<li key={row}>{row}</li>
))}
</ul>
<TablePagination
page={pagination.page}
totalPages={pagination.totalPages}
total={pagination.total}
onPageChange={pagination.setPage}
/>
</div>
);
}
describe("useClientPagination + TablePagination", () => {
it("pages rows 20 at a time and resets when the filter changes", async () => {
const user = userEvent.setup();
render(<Harness count={45} />);
expect(screen.getByText("usage.totalRecords:45")).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(20);
expect(screen.getByText("/ 3")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "usage.prevPage" }),
).toBeDisabled();
await user.click(screen.getByRole("button", { name: "usage.nextPage" }));
expect(screen.getByText("row-21")).toBeInTheDocument();
// 输入页码跳到最后一页:只剩 5 行
const input = screen.getByRole("textbox", {
name: "usage.pageInputPlaceholder",
});
await user.clear(input);
await user.type(input, "3{Enter}");
expect(screen.getAllByRole("listitem")).toHaveLength(5);
expect(
screen.getByRole("button", { name: "usage.nextPage" }),
).toBeDisabled();
// 超出范围的页码不生效
await user.clear(input);
await user.type(input, "9{Enter}");
expect(screen.getByText("row-41")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "change-filter" }));
expect(screen.getByText("row-1")).toBeInTheDocument();
});
it("clamps to the last page when rows shrink", async () => {
const user = userEvent.setup();
const { rerender } = render(<Harness count={45} />);
await user.click(screen.getByRole("button", { name: "usage.nextPage" }));
await user.click(screen.getByRole("button", { name: "usage.nextPage" }));
expect(screen.getByText("row-41")).toBeInTheDocument();
rerender(<Harness count={25} />);
expect(screen.getByText("row-21")).toBeInTheDocument();
expect(screen.getByText("/ 2")).toBeInTheDocument();
});
});