Răsfoiți Sursa

fix: stabilize usage log virtual scrolling

ding113 3 săptămâni în urmă
părinte
comite
e2bedea254

+ 54 - 54
src/actions/my-usage.ts

@@ -16,9 +16,10 @@ import { LEDGER_BILLING_CONDITION } from "@/repository/_shared/ledger-conditions
 import { EXCLUDE_WARMUP_CONDITION } from "@/repository/_shared/message-request-conditions";
 import { getSystemSettings } from "@/repository/system-config";
 import {
-  findUsageLogsForKeySlim,
+  findUsageLogsForKeyBatch,
   getDistinctEndpointsForKey,
   getDistinctModelsForKey,
+  type UsageLogSlimBatchResult,
   type UsageLogSummary,
 } from "@/repository/usage-logs";
 import type { BillingModelSource } from "@/types/system-config";
@@ -168,11 +169,10 @@ export interface MyUsageLogEntry {
   cacheTtlApplied: string | null;
 }
 
-export interface MyUsageLogsResult {
+export interface MyUsageLogsBatchResult {
   logs: MyUsageLogEntry[];
-  total: number;
-  page: number;
-  pageSize: number;
+  nextCursor: { createdAt: string; id: number } | null;
+  hasMore: boolean;
   currencyCode: CurrencyCode;
   billingModelSource: BillingModelSource;
 }
@@ -469,7 +469,7 @@ export async function getMyTodayStats(): Promise<ActionResult<MyTodayStats>> {
   }
 }
 
-export interface MyUsageLogsFilters {
+export interface MyUsageLogsBatchFilters {
   startDate?: string;
   endDate?: string;
   /** Session ID(精确匹配;空字符串/空白视为不筛选) */
@@ -479,30 +479,61 @@ export interface MyUsageLogsFilters {
   excludeStatusCode200?: boolean;
   endpoint?: string;
   minRetryCount?: number;
-  page?: number;
-  pageSize?: number;
+  cursor?: { createdAt: string; id: number };
+  limit?: number;
 }
 
-export async function getMyUsageLogs(
-  filters: MyUsageLogsFilters = {}
-): Promise<ActionResult<MyUsageLogsResult>> {
+function mapMyUsageLogEntries(
+  result: Pick<UsageLogSlimBatchResult, "logs">,
+  billingModelSource: BillingModelSource
+): MyUsageLogEntry[] {
+  return result.logs.map((log) => {
+    const modelRedirect =
+      log.originalModel && log.model && log.originalModel !== log.model
+        ? `${log.originalModel} → ${log.model}`
+        : null;
+
+    const billingModel =
+      (billingModelSource === "original" ? log.originalModel : log.model) ?? null;
+
+    return {
+      id: log.id,
+      createdAt: log.createdAt,
+      model: log.model,
+      billingModel,
+      anthropicEffort: log.anthropicEffort ?? null,
+      modelRedirect,
+      inputTokens: log.inputTokens ?? 0,
+      outputTokens: log.outputTokens ?? 0,
+      cost: log.costUsd ? Number(log.costUsd) : 0,
+      statusCode: log.statusCode,
+      duration: log.durationMs,
+      endpoint: log.endpoint,
+      cacheCreationInputTokens: log.cacheCreationInputTokens ?? null,
+      cacheReadInputTokens: log.cacheReadInputTokens ?? null,
+      cacheCreation5mInputTokens: log.cacheCreation5mInputTokens ?? null,
+      cacheCreation1hInputTokens: log.cacheCreation1hInputTokens ?? null,
+      cacheTtlApplied: log.cacheTtlApplied ?? null,
+    };
+  });
+}
+
+export async function getMyUsageLogsBatch(
+  filters: MyUsageLogsBatchFilters = {}
+): Promise<ActionResult<MyUsageLogsBatchResult>> {
   try {
     const session = await getSession({ allowReadOnlyAccess: true });
     if (!session) return { ok: false, error: "Unauthorized" };
 
     const settings = await getSystemSettings();
-
-    const rawPageSize = filters.pageSize && filters.pageSize > 0 ? filters.pageSize : 20;
-    const pageSize = Math.min(rawPageSize, 100);
-    const page = filters.page && filters.page > 0 ? filters.page : 1;
-
     const timezone = await resolveSystemTimezone();
     const { startTime, endTime } = parseDateRangeInServerTimezone(
       filters.startDate,
       filters.endDate,
       timezone
     );
-    const result = await findUsageLogsForKeySlim({
+    const limit = filters.limit && filters.limit > 0 ? Math.min(filters.limit, 100) : 20;
+    const result = await findUsageLogsForKeyBatch({
       keyString: session.key.key,
       sessionId: filters.sessionId,
       startTime,
@@ -512,53 +543,22 @@ export async function getMyUsageLogs(
       excludeStatusCode200: filters.excludeStatusCode200,
       endpoint: filters.endpoint,
       minRetryCount: filters.minRetryCount,
-      page,
-      pageSize,
-    });
-
-    const logs: MyUsageLogEntry[] = result.logs.map((log) => {
-      const modelRedirect =
-        log.originalModel && log.model && log.originalModel !== log.model
-          ? `${log.originalModel} → ${log.model}`
-          : null;
-
-      const billingModel =
-        (settings.billingModelSource === "original" ? log.originalModel : log.model) ?? null;
-
-      return {
-        id: log.id,
-        createdAt: log.createdAt,
-        model: log.model,
-        billingModel,
-        anthropicEffort: log.anthropicEffort ?? null,
-        modelRedirect,
-        inputTokens: log.inputTokens ?? 0,
-        outputTokens: log.outputTokens ?? 0,
-        cost: log.costUsd ? Number(log.costUsd) : 0,
-        statusCode: log.statusCode,
-        duration: log.durationMs,
-        endpoint: log.endpoint,
-        cacheCreationInputTokens: log.cacheCreationInputTokens ?? null,
-        cacheReadInputTokens: log.cacheReadInputTokens ?? null,
-        cacheCreation5mInputTokens: log.cacheCreation5mInputTokens ?? null,
-        cacheCreation1hInputTokens: log.cacheCreation1hInputTokens ?? null,
-        cacheTtlApplied: log.cacheTtlApplied ?? null,
-      };
+      cursor: filters.cursor,
+      limit,
     });
 
     return {
       ok: true,
       data: {
-        logs,
-        total: result.total,
-        page,
-        pageSize,
+        logs: mapMyUsageLogEntries(result, settings.billingModelSource),
+        nextCursor: result.nextCursor,
+        hasMore: result.hasMore,
         currencyCode: settings.currencyDisplay,
         billingModelSource: settings.billingModelSource,
       },
     };
   } catch (error) {
-    logger.error("[my-usage] getMyUsageLogs failed", error);
+    logger.error("[my-usage] getMyUsageLogsBatch failed", error);
     return { ok: false, error: "Failed to get usage logs" };
   }
 }

+ 29 - 9
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx

@@ -12,21 +12,25 @@ let mockIsError = false;
 let mockError: unknown = null;
 let mockHasNextPage = false;
 let mockIsFetchingNextPage = false;
+const useInfiniteQuerySpy = vi.hoisted(() => vi.fn());
 
 vi.mock("next-intl", () => ({
   useTranslations: () => (key: string) => key,
 }));
 
 vi.mock("@tanstack/react-query", () => ({
-  useInfiniteQuery: () => ({
-    data: { pages: [{ logs: mockLogs, nextCursor: null, hasMore: false }] },
-    fetchNextPage: vi.fn(),
-    hasNextPage: mockHasNextPage,
-    isFetchingNextPage: mockIsFetchingNextPage,
-    isLoading: mockIsLoading,
-    isError: mockIsError,
-    error: mockError,
-  }),
+  useInfiniteQuery: (options: unknown) => {
+    useInfiniteQuerySpy(options);
+    return {
+      data: { pages: [{ logs: mockLogs, nextCursor: null, hasMore: false }] },
+      fetchNextPage: vi.fn(),
+      hasNextPage: mockHasNextPage,
+      isFetchingNextPage: mockIsFetchingNextPage,
+      isLoading: mockIsLoading,
+      isError: mockIsError,
+      error: mockError,
+    };
+  },
 }));
 
 vi.mock("@/hooks/use-virtualizer", () => ({
@@ -144,6 +148,22 @@ function makeLog(overrides: Partial<UsageLogRow>): UsageLogRow {
 }
 
 describe("virtualized-logs-table multiplier badge", () => {
+  test("does not cap cached pages so deep scroll can return to the latest rows", () => {
+    mockIsLoading = false;
+    mockIsError = false;
+    mockError = null;
+    mockHasNextPage = true;
+    mockIsFetchingNextPage = false;
+    mockLogs = [makeLog({ id: 1 })];
+    useInfiniteQuerySpy.mockClear();
+
+    renderToStaticMarkup(<VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />);
+
+    const options = useInfiniteQuerySpy.mock.calls[0]?.[0] as { maxPages?: number } | undefined;
+    expect(options).toBeDefined();
+    expect(options?.maxPages).toBeUndefined();
+  });
+
   test("renders loading/error/empty states", () => {
     mockIsError = false;
     mockError = null;

+ 31 - 37
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx

@@ -10,7 +10,7 @@ import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { RelativeTime } from "@/components/ui/relative-time";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import { useVirtualizer } from "@/hooks/use-virtualizer";
+import { useVirtualizedInfiniteList } from "@/hooks/use-virtualized-infinite-list";
 import type { LogsTableColumn } from "@/lib/column-visibility";
 import { cn, formatTokenAmount } from "@/lib/utils";
 import { copyTextToClipboard } from "@/lib/utils/clipboard";
@@ -80,9 +80,8 @@ export function VirtualizedLogsTable({
   const getPricingSourceLabel = (source: string) =>
     t(`logs.billingDetails.pricingSource.${source}`);
   const tChain = useTranslations("provider-chain");
-  const parentRef = useRef<HTMLDivElement>(null);
-  const [showScrollToTop, setShowScrollToTop] = useState(false);
-  const shouldPoll = autoRefreshEnabled && !showScrollToTop;
+  const [isHistoryBrowsing, setIsHistoryBrowsing] = useState(false);
+  const shouldPoll = autoRefreshEnabled && !isHistoryBrowsing;
 
   const hideProviderColumn = hiddenColumns?.includes("provider") ?? false;
   const hideUserColumn = hiddenColumns?.includes("user") ?? false;
@@ -137,51 +136,46 @@ export function VirtualizedLogsTable({
         if (query.state.fetchStatus !== "idle") return false;
         return autoRefreshIntervalMs;
       },
-      maxPages: 5,
     });
 
   // Flatten all pages into a single array
   const pages = data?.pages;
   const allLogs = useMemo(() => pages?.flatMap((page) => page.logs) ?? [], [pages]);
+  const filtersResetKey = useMemo(() => JSON.stringify(filters), [filters]);
+  const previousFiltersResetKeyRef = useRef(filtersResetKey);
 
-  // Virtual list setup
-  const rowVirtualizer = useVirtualizer({
-    count: hasNextPage ? allLogs.length + 1 : allLogs.length,
-    getScrollElement: () => parentRef.current,
+  const getItemKey = useCallback(
+    (index: number) => allLogs[index]?.id ?? `loader-${index}`,
+    [allLogs]
+  );
+
+  const {
+    parentRef,
+    rowVirtualizer,
+    virtualItems,
+    showScrollToTop,
+    handleScroll,
+    scrollToTop,
+    resetScrollPosition,
+  } = useVirtualizedInfiniteList({
+    itemCount: allLogs.length,
+    hasNextPage,
+    isFetchingNextPage,
+    fetchNextPage,
     estimateSize: () => ROW_HEIGHT,
     overscan: 10,
+    getItemKey,
   });
 
-  const virtualItems = rowVirtualizer.getVirtualItems();
-  const lastItemIndex = virtualItems[virtualItems.length - 1]?.index ?? -1;
-
-  // Auto-fetch next page when scrolling near the bottom
   useEffect(() => {
-    // If the last visible item is a loading row or near the end, fetch more
-    if (lastItemIndex >= allLogs.length - 5 && hasNextPage && !isFetchingNextPage) {
-      fetchNextPage();
-    }
-  }, [lastItemIndex, hasNextPage, isFetchingNextPage, allLogs.length, fetchNextPage]);
-
-  // Track scroll position for "scroll to top" button
-  const handleScroll = useCallback(() => {
-    if (parentRef.current) {
-      setShowScrollToTop(parentRef.current.scrollTop > 500);
-    }
-  }, []);
-
-  // Scroll to top handler
-  const scrollToTop = useCallback(() => {
-    parentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
-  }, []);
-
-  // Reset scroll when filters change
-  // biome-ignore lint/correctness/useExhaustiveDependencies: `filters` is an intentional trigger
+    setIsHistoryBrowsing(showScrollToTop);
+  }, [showScrollToTop]);
+
   useEffect(() => {
-    if (parentRef.current) {
-      parentRef.current.scrollTop = 0;
-    }
-  }, [filters]);
+    if (previousFiltersResetKeyRef.current === filtersResetKey) return;
+    previousFiltersResetKeyRef.current = filtersResetKey;
+    resetScrollPosition();
+  });
 
   if (isLoading) {
     return (

+ 109 - 0
src/app/[locale]/my-usage/_components/usage-logs-section.test.tsx

@@ -0,0 +1,109 @@
+import type { ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import { act } from "react";
+import { describe, expect, test, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+  useInfiniteQuery: vi.fn(),
+  getMyUsageLogs: vi.fn(),
+  getMyUsageLogsBatch: vi.fn(),
+  getMyAvailableModels: vi.fn(),
+  getMyAvailableEndpoints: vi.fn(),
+}));
+
+vi.mock("next-intl", () => ({
+  useTranslations: () => (key: string, values?: Record<string, unknown>) =>
+    values ? `${key}:${JSON.stringify(values)}` : key,
+  useTimeZone: () => "UTC",
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+  useInfiniteQuery: mocks.useInfiniteQuery,
+}));
+
+vi.mock("@/actions/my-usage", () => ({
+  getMyUsageLogs: mocks.getMyUsageLogs,
+  getMyUsageLogsBatch: mocks.getMyUsageLogsBatch,
+  getMyAvailableModels: mocks.getMyAvailableModels,
+  getMyAvailableEndpoints: mocks.getMyAvailableEndpoints,
+}));
+
+vi.mock("@/app/[locale]/dashboard/logs/_components/logs-date-range-picker", () => ({
+  LogsDateRangePicker: () => <div data-testid="logs-date-range-picker" />,
+}));
+
+vi.mock("@/components/ui/collapsible", () => ({
+  Collapsible: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  CollapsibleContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  CollapsibleTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+}));
+
+vi.mock("@/components/ui/select", () => ({
+  Select: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  SelectTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  SelectValue: () => <div />,
+  SelectContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  SelectItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+}));
+
+vi.mock("@/components/ui/button", () => ({
+  Button: ({ children, ...props }: React.ComponentProps<"button">) => (
+    <button type="button" {...props}>
+      {children}
+    </button>
+  ),
+}));
+
+vi.mock("@/components/ui/badge", () => ({
+  Badge: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+}));
+
+vi.mock("@/components/ui/input", () => ({
+  Input: (props: React.ComponentProps<"input">) => <input {...props} />,
+}));
+
+vi.mock("@/components/ui/label", () => ({
+  Label: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
+}));
+
+vi.mock("./usage-logs-table", () => ({
+  UsageLogsTable: () => <div data-testid="usage-logs-table" />,
+}));
+
+import { UsageLogsSection } from "./usage-logs-section";
+
+describe("my-usage usage logs section", () => {
+  test("uses infinite query instead of the old page-based getMyUsageLogs flow", async () => {
+    mocks.useInfiniteQuery.mockReturnValue({
+      data: { pages: [{ logs: [], nextCursor: null, hasMore: false }] },
+      fetchNextPage: vi.fn(),
+      hasNextPage: false,
+      isFetchingNextPage: false,
+      isLoading: false,
+      isError: false,
+      error: null,
+    });
+    mocks.getMyUsageLogs.mockResolvedValue({
+      ok: true,
+      data: { logs: [], total: 0, page: 1, pageSize: 20, currencyCode: "USD" },
+    });
+    mocks.getMyAvailableModels.mockResolvedValue({ ok: true, data: [] });
+    mocks.getMyAvailableEndpoints.mockResolvedValue({ ok: true, data: [] });
+
+    const container = document.createElement("div");
+    document.body.appendChild(container);
+    const root = createRoot(container);
+
+    await act(async () => {
+      root.render(<UsageLogsSection defaultOpen />);
+    });
+
+    expect(mocks.useInfiniteQuery).toHaveBeenCalled();
+    expect(mocks.getMyUsageLogs).not.toHaveBeenCalled();
+
+    await act(async () => {
+      root.unmount();
+    });
+    container.remove();
+  });
+});

+ 107 - 169
src/app/[locale]/my-usage/_components/usage-logs-section.tsx

@@ -1,13 +1,13 @@
 "use client";
 
+import { useInfiniteQuery } from "@tanstack/react-query";
 import { Check, ChevronDown, Filter, Loader2, RefreshCw, ScrollText, X } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
 import {
   getMyAvailableEndpoints,
   getMyAvailableModels,
-  getMyUsageLogs,
-  type MyUsageLogsResult,
+  getMyUsageLogsBatch,
 } from "@/actions/my-usage";
 import { LogsDateRangePicker } from "@/app/[locale]/dashboard/logs/_components/logs-date-range-picker";
 import { Badge } from "@/components/ui/badge";
@@ -25,9 +25,9 @@ import {
 import { cn } from "@/lib/utils";
 import { UsageLogsTable } from "./usage-logs-table";
 
+const BATCH_SIZE = 20;
+
 interface UsageLogsSectionProps {
-  initialData?: MyUsageLogsResult | null;
-  loading?: boolean;
   autoRefreshSeconds?: number;
   defaultOpen?: boolean;
   serverTimeZone?: string;
@@ -41,12 +41,9 @@ interface Filters {
   excludeStatusCode200?: boolean;
   endpoint?: string;
   minRetryCount?: number;
-  page?: number;
 }
 
 export function UsageLogsSection({
-  initialData = null,
-  loading = false,
   autoRefreshSeconds,
   defaultOpen = false,
   serverTimeZone,
@@ -60,14 +57,70 @@ export function UsageLogsSection({
   const [endpoints, setEndpoints] = useState<string[]>([]);
   const [isModelsLoading, setIsModelsLoading] = useState(true);
   const [isEndpointsLoading, setIsEndpointsLoading] = useState(true);
-  const [draftFilters, setDraftFilters] = useState<Filters>({ page: 1 });
-  const [appliedFilters, setAppliedFilters] = useState<Filters>({ page: 1 });
-  const [data, setData] = useState<MyUsageLogsResult | null>(initialData);
-  const [isPending, startTransition] = useTransition();
-  const [error, setError] = useState<string | null>(null);
+  const [draftFilters, setDraftFilters] = useState<Filters>({});
+  const [appliedFilters, setAppliedFilters] = useState<Filters>({});
+  const [isBrowsingHistory, setIsBrowsingHistory] = useState(false);
+
+  useEffect(() => {
+    setIsModelsLoading(true);
+    setIsEndpointsLoading(true);
+
+    void getMyAvailableModels()
+      .then((modelsResult) => {
+        if (modelsResult.ok && modelsResult.data) {
+          setModels(modelsResult.data);
+        }
+      })
+      .finally(() => setIsModelsLoading(false));
 
-  // Compute metrics for header summary
-  const logs = data?.logs ?? [];
+    void getMyAvailableEndpoints()
+      .then((endpointsResult) => {
+        if (endpointsResult.ok && endpointsResult.data) {
+          setEndpoints(endpointsResult.data);
+        }
+      })
+      .finally(() => setIsEndpointsLoading(false));
+  }, []);
+
+  const query = useInfiniteQuery({
+    queryKey: ["my-usage-logs-batch", appliedFilters],
+    queryFn: async ({ pageParam }) => {
+      const result = await getMyUsageLogsBatch({
+        ...appliedFilters,
+        cursor: pageParam,
+        limit: BATCH_SIZE,
+      });
+      if (!result.ok) {
+        throw new Error(result.error);
+      }
+      return result.data;
+    },
+    initialPageParam: undefined as { createdAt: string; id: number } | undefined,
+    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+    staleTime: 30000,
+    refetchOnWindowFocus: false,
+    refetchInterval: autoRefreshSeconds
+      ? (query) => {
+          if (isBrowsingHistory) return false;
+          if (query.state.fetchStatus !== "idle") return false;
+          return autoRefreshSeconds * 1000;
+        }
+      : false,
+  });
+  const {
+    data,
+    fetchNextPage,
+    hasNextPage = false,
+    isFetchingNextPage,
+    isLoading,
+    isError,
+    error,
+    isRefetching = false,
+  } = query;
+  const refetch = query.refetch ?? (async (): Promise<unknown> => undefined);
+
+  const logs = useMemo(() => data?.pages.flatMap((page) => page.logs) ?? [], [data]);
+  const latestPage = data?.pages[0];
 
   const activeFiltersCount = useMemo(() => {
     let count = 0;
@@ -79,10 +132,7 @@ export function UsageLogsSection({
     return count;
   }, [appliedFilters]);
 
-  const lastLog = useMemo(() => {
-    if (!logs || logs.length === 0) return null;
-    return logs[0]; // First log is the most recent (sorted by createdAt DESC)
-  }, [logs]);
+  const lastLog = logs[0] ?? null;
 
   const lastStatusText = useMemo(() => {
     if (!lastLog?.createdAt) return null;
@@ -99,7 +149,7 @@ export function UsageLogsSection({
   }, [lastLog]);
 
   const successRate = useMemo(() => {
-    if (!logs || logs.length === 0) return null;
+    if (logs.length === 0) return null;
     const successCount = logs.filter((log) => log.statusCode && log.statusCode < 400).length;
     return Math.round((successCount / logs.length) * 100);
   }, [logs]);
@@ -111,133 +161,38 @@ export function UsageLogsSection({
     return "";
   }, [lastLog]);
 
-  // Sync initialData from parent when it becomes available
-  // (useState only uses initialData on first mount, not on subsequent updates)
-  useEffect(() => {
-    if (initialData && !data) {
-      setData(initialData);
-    }
-  }, [initialData, data]);
-
-  useEffect(() => {
-    setIsModelsLoading(true);
-    setIsEndpointsLoading(true);
-
-    void getMyAvailableModels()
-      .then((modelsResult) => {
-        if (modelsResult.ok && modelsResult.data) {
-          setModels(modelsResult.data);
-        }
-      })
-      .finally(() => setIsModelsLoading(false));
-
-    void getMyAvailableEndpoints()
-      .then((endpointsResult) => {
-        if (endpointsResult.ok && endpointsResult.data) {
-          setEndpoints(endpointsResult.data);
-        }
-      })
-      .finally(() => setIsEndpointsLoading(false));
-  }, []);
-
-  const loadLogs = useCallback(
-    (nextFilters: Filters) => {
-      startTransition(async () => {
-        const result = await getMyUsageLogs(nextFilters);
-        if (result.ok && result.data) {
-          setData(result.data);
-          setAppliedFilters(nextFilters);
-          setError(null);
-        } else {
-          setError(!result.ok && "error" in result ? result.error : t("loadFailed"));
-        }
-      });
-    },
-    [t]
-  );
-
-  useEffect(() => {
-    // initial load if not provided
-    if (data) return;
-    if (!initialData && !loading) {
-      loadLogs({ page: 1 });
-    }
-  }, [data, initialData, loading, loadLogs]);
-
-  // Auto-refresh polling (only when on page 1 to avoid disrupting history browsing)
-  const intervalRef = useRef<NodeJS.Timeout | null>(null);
-
-  useEffect(() => {
-    if (!autoRefreshSeconds || autoRefreshSeconds <= 0) {
-      return;
-    }
-
-    const pollIntervalMs = autoRefreshSeconds * 1000;
-
-    const startPolling = () => {
-      if (intervalRef.current) {
-        clearInterval(intervalRef.current);
-      }
-
-      intervalRef.current = setInterval(() => {
-        // Only auto-refresh when on page 1
-        if ((appliedFilters.page ?? 1) === 1) {
-          loadLogs(appliedFilters);
-        }
-      }, pollIntervalMs);
-    };
-
-    const stopPolling = () => {
-      if (intervalRef.current) {
-        clearInterval(intervalRef.current);
-        intervalRef.current = null;
-      }
-    };
-
-    const handleVisibilityChange = () => {
-      if (document.hidden) {
-        stopPolling();
-      } else {
-        // Refresh immediately when tab becomes visible (only if on page 1)
-        if ((appliedFilters.page ?? 1) === 1) {
-          loadLogs(appliedFilters);
-        }
-        startPolling();
-      }
-    };
-
-    startPolling();
-    document.addEventListener("visibilitychange", handleVisibilityChange);
-
-    return () => {
-      stopPolling();
-      document.removeEventListener("visibilitychange", handleVisibilityChange);
-    };
-  }, [autoRefreshSeconds, appliedFilters, loadLogs]);
-
   const handleFilterChange = (changes: Partial<Filters>) => {
-    setDraftFilters((prev) => ({ ...prev, ...changes, page: 1 }));
+    setDraftFilters((prev) => ({ ...prev, ...changes }));
   };
 
   const handleApply = () => {
-    loadLogs({ ...draftFilters, page: 1 });
+    const nextFilters = { ...draftFilters };
+    if (JSON.stringify(nextFilters) === JSON.stringify(appliedFilters)) {
+      void refetch();
+      return;
+    }
+    setAppliedFilters(nextFilters);
   };
 
   const handleReset = () => {
-    setDraftFilters({ page: 1 });
-    loadLogs({ page: 1 });
+    setDraftFilters({});
+    if (Object.keys(appliedFilters).length === 0) {
+      void refetch();
+      return;
+    }
+    setAppliedFilters({});
   };
 
   const handleDateRangeChange = (range: { startDate?: string; endDate?: string }) => {
     handleFilterChange(range);
   };
 
-  const handlePageChange = (page: number) => {
-    loadLogs({ ...appliedFilters, page });
-  };
+  const handleLoadMore = useCallback(() => {
+    void fetchNextPage();
+  }, [fetchNextPage]);
 
-  const isInitialLoading = loading || (!data && isPending);
-  const isRefreshing = isPending && Boolean(data);
+  const isRefreshing = isRefetching && !isFetchingNextPage && logs.length > 0;
+  const errorMessage = isError ? (error instanceof Error ? error.message : t("loadFailed")) : null;
 
   return (
     <Collapsible open={isOpen} onOpenChange={setIsOpen}>
@@ -250,7 +205,6 @@ export function UsageLogsSection({
               isOpen && "border-b"
             )}
           >
-            {/* Icon + Title */}
             <div className="flex items-center gap-3">
               <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
                 <ScrollText className="h-4 w-4" />
@@ -258,11 +212,8 @@ export function UsageLogsSection({
               <span className="text-sm font-semibold">{tCollapsible("title")}</span>
             </div>
 
-            {/* Header Summary */}
             <div className="flex items-center gap-3">
-              {/* Desktop Summary */}
               <div className="hidden sm:flex items-center gap-2 text-sm">
-                {/* Last Status */}
                 {lastLog ? (
                   <span className={cn("font-mono", lastStatusColor)}>
                     {tCollapsible("lastStatus", {
@@ -276,7 +227,6 @@ export function UsageLogsSection({
 
                 <span className="text-muted-foreground">|</span>
 
-                {/* Success Rate */}
                 {successRate !== null ? (
                   <span
                     className={cn(
@@ -291,7 +241,6 @@ export function UsageLogsSection({
                   </span>
                 ) : null}
 
-                {/* Active Filters Badge */}
                 {activeFiltersCount > 0 && (
                   <>
                     <span className="text-muted-foreground">|</span>
@@ -302,7 +251,6 @@ export function UsageLogsSection({
                   </>
                 )}
 
-                {/* Auto-refresh */}
                 {autoRefreshSeconds && (
                   <>
                     <span className="text-muted-foreground">|</span>
@@ -312,9 +260,7 @@ export function UsageLogsSection({
                 )}
               </div>
 
-              {/* Mobile Summary */}
               <div className="flex items-center gap-1.5 text-xs sm:hidden">
-                {/* Last Status - compact */}
                 {lastLog ? (
                   <span className={cn("font-mono", lastStatusColor)}>
                     {lastLog.statusCode ?? "-"} ({lastStatusText ?? "-"})
@@ -325,7 +271,6 @@ export function UsageLogsSection({
 
                 <span className="text-muted-foreground">|</span>
 
-                {/* Success Rate - compact */}
                 {successRate !== null ? (
                   <span
                     className={cn(
@@ -338,7 +283,6 @@ export function UsageLogsSection({
                   </span>
                 ) : null}
 
-                {/* Filters + Refresh */}
                 {activeFiltersCount > 0 && (
                   <>
                     <span className="text-muted-foreground">|</span>
@@ -355,7 +299,6 @@ export function UsageLogsSection({
                 )}
               </div>
 
-              {/* Chevron */}
               <ChevronDown
                 className={cn(
                   "h-4 w-4 text-muted-foreground transition-transform duration-200",
@@ -385,9 +328,7 @@ export function UsageLogsSection({
                 <Select
                   value={draftFilters.model ?? "__all__"}
                   onValueChange={(value) =>
-                    handleFilterChange({
-                      model: value === "__all__" ? undefined : value,
-                    })
+                    handleFilterChange({ model: value === "__all__" ? undefined : value })
                   }
                   disabled={isModelsLoading}
                 >
@@ -411,9 +352,7 @@ export function UsageLogsSection({
                 <Select
                   value={draftFilters.endpoint ?? "__all__"}
                   onValueChange={(value) =>
-                    handleFilterChange({
-                      endpoint: value === "__all__" ? undefined : value,
-                    })
+                    handleFilterChange({ endpoint: value === "__all__" ? undefined : value })
                   }
                   disabled={isEndpointsLoading}
                 >
@@ -449,7 +388,9 @@ export function UsageLogsSection({
                   onValueChange={(value) =>
                     handleFilterChange({
                       statusCode:
-                        value === "__all__" || value === "!200" ? undefined : parseInt(value, 10),
+                        value === "__all__" || value === "!200"
+                          ? undefined
+                          : Number.parseInt(value, 10),
                       excludeStatusCode200: value === "!200",
                     })
                   }
@@ -478,7 +419,9 @@ export function UsageLogsSection({
                   placeholder={tDashboard("logs.filters.minRetryCountPlaceholder")}
                   onChange={(e) =>
                     handleFilterChange({
-                      minRetryCount: e.target.value ? parseInt(e.target.value, 10) : undefined,
+                      minRetryCount: e.target.value
+                        ? Number.parseInt(e.target.value, 10)
+                        : undefined,
                     })
                   }
                 />
@@ -486,21 +429,14 @@ export function UsageLogsSection({
             </div>
 
             <div className="flex flex-wrap items-center gap-2">
-              <Button size="sm" onClick={handleApply} disabled={isPending || loading}>
+              <Button size="sm" onClick={handleApply} disabled={isLoading}>
                 {t("filters.apply")}
               </Button>
-              <Button
-                size="sm"
-                variant="outline"
-                onClick={handleReset}
-                disabled={isPending || loading}
-              >
+              <Button size="sm" variant="outline" onClick={handleReset} disabled={isLoading}>
                 {t("filters.reset")}
               </Button>
             </div>
 
-            {error ? <p className="text-sm text-destructive">{error}</p> : null}
-
             {isRefreshing ? (
               <div className="flex items-center gap-2 text-xs text-muted-foreground">
                 <Loader2 className="h-3 w-3 animate-spin" />
@@ -509,14 +445,16 @@ export function UsageLogsSection({
             ) : null}
 
             <UsageLogsTable
-              logs={data?.logs ?? []}
-              total={data?.total ?? 0}
-              page={appliedFilters.page ?? 1}
-              pageSize={data?.pageSize ?? 20}
-              onPageChange={handlePageChange}
-              currencyCode={data?.currencyCode}
-              loading={isInitialLoading}
+              logs={logs}
+              hasNextPage={hasNextPage}
+              isFetchingNextPage={isFetchingNextPage}
+              currencyCode={latestPage?.currencyCode}
+              loading={isLoading}
               loadingLabel={tCommon("loading")}
+              errorMessage={errorMessage}
+              onLoadMore={handleLoadMore}
+              resetScrollKey={appliedFilters}
+              onHistoryBrowsingChange={setIsBrowsingHistory}
             />
           </div>
         </CollapsibleContent>

+ 250 - 161
src/app/[locale]/my-usage/_components/usage-logs-table.tsx

@@ -1,50 +1,53 @@
 "use client";
 
 import { formatInTimeZone } from "date-fns-tz";
+import { ArrowUp, Loader2 } from "lucide-react";
 import { useTimeZone, useTranslations } from "next-intl";
-import { useCallback } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
 import { toast } from "sonner";
 import type { MyUsageLogEntry } from "@/actions/my-usage";
 import { ModelVendorIcon } from "@/components/customs/model-vendor-icon";
 import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
 import { Skeleton } from "@/components/ui/skeleton";
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeader,
-  TableRow,
-} from "@/components/ui/table";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { useVirtualizedInfiniteList } from "@/hooks/use-virtualized-infinite-list";
 import { CURRENCY_CONFIG, type CurrencyCode } from "@/lib/utils";
 import { copyTextToClipboard } from "@/lib/utils/clipboard";
 
+const ROW_HEIGHT = 80;
+
 interface UsageLogsTableProps {
   logs: MyUsageLogEntry[];
-  total: number;
-  page: number;
-  pageSize: number;
-  onPageChange: (page: number) => void;
+  hasNextPage: boolean;
+  isFetchingNextPage: boolean;
   currencyCode?: CurrencyCode;
   loading?: boolean;
   loadingLabel?: string;
+  errorMessage?: string | null;
+  onLoadMore?: () => void;
+  resetScrollKey?: unknown;
+  onHistoryBrowsingChange?: (isBrowsingHistory: boolean) => void;
 }
 
 export function UsageLogsTable({
   logs,
-  total,
-  page,
-  pageSize,
-  onPageChange,
+  hasNextPage,
+  isFetchingNextPage,
   currencyCode = "USD",
   loading = false,
   loadingLabel,
+  errorMessage,
+  onLoadMore,
+  resetScrollKey,
+  onHistoryBrowsingChange,
 }: UsageLogsTableProps) {
   const t = useTranslations("myUsage.logs");
   const tCommon = useTranslations("common");
+  const tDashboard = useTranslations("dashboard");
   const timeZone = useTimeZone() ?? "UTC";
-  const totalPages = Math.max(1, Math.ceil(total / pageSize));
+  const resolvedResetKey = useMemo(() => JSON.stringify(resetScrollKey ?? null), [resetScrollKey]);
+  const previousResetKeyRef = useRef(resolvedResetKey);
 
   const formatTokenAmount = (value: number | null | undefined): string => {
     if (value == null || value === 0) return "-";
@@ -60,157 +63,243 @@ export function UsageLogsTable({
     [tCommon]
   );
 
+  const getItemKey = useCallback((index: number) => logs[index]?.id ?? `loader-${index}`, [logs]);
+
+  const {
+    parentRef,
+    rowVirtualizer,
+    virtualItems,
+    showScrollToTop,
+    handleScroll,
+    scrollToTop,
+    resetScrollPosition,
+  } = useVirtualizedInfiniteList({
+    itemCount: logs.length,
+    hasNextPage,
+    isFetchingNextPage,
+    fetchNextPage: () => {
+      onLoadMore?.();
+      return undefined;
+    },
+    estimateSize: () => ROW_HEIGHT,
+    overscan: 8,
+    getItemKey,
+  });
+
+  useEffect(() => {
+    onHistoryBrowsingChange?.(showScrollToTop);
+  }, [showScrollToTop, onHistoryBrowsingChange]);
+
+  useEffect(() => {
+    if (previousResetKeyRef.current === resolvedResetKey) return;
+    previousResetKeyRef.current = resolvedResetKey;
+    resetScrollPosition();
+  });
+
+  useEffect(() => {
+    return () => {
+      onHistoryBrowsingChange?.(false);
+    };
+  }, [onHistoryBrowsingChange]);
+
+  if (loading && logs.length === 0) {
+    return (
+      <div className="space-y-3">
+        <div className="rounded-md border">
+          <div className="grid grid-cols-7 gap-3 p-3">
+            {Array.from({ length: 6 }).map((_, rowIndex) => (
+              <div key={rowIndex} className="contents">
+                {Array.from({ length: 7 }).map((__, cellIndex) => (
+                  <Skeleton key={`${rowIndex}-${cellIndex}`} className="h-4 w-full" />
+                ))}
+              </div>
+            ))}
+          </div>
+        </div>
+        {loadingLabel ? <div className="text-xs text-muted-foreground">{loadingLabel}</div> : null}
+      </div>
+    );
+  }
+
+  if (errorMessage && logs.length === 0) {
+    return <div className="text-center py-8 text-destructive">{errorMessage}</div>;
+  }
+
+  if (logs.length === 0) {
+    return <div className="text-center py-8 text-muted-foreground">{t("noLogs")}</div>;
+  }
+
   return (
-    <div className="space-y-3">
-      <div className="overflow-x-auto rounded-md border">
-        <Table>
-          <TableHeader>
-            <TableRow>
-              <TableHead>{t("table.time")}</TableHead>
-              <TableHead>{t("table.model")}</TableHead>
-              <TableHead className="text-right">{t("table.tokens")}</TableHead>
-              <TableHead className="text-right">{t("table.cacheWrite")}</TableHead>
-              <TableHead className="text-right">{t("table.cacheRead")}</TableHead>
-              <TableHead className="text-right">{t("table.cost")}</TableHead>
-              <TableHead>{t("table.status")}</TableHead>
-            </TableRow>
-          </TableHeader>
-          <TableBody>
-            {loading ? (
-              Array.from({ length: 6 }).map((_, rowIndex) => (
-                <TableRow key={`skeleton-${rowIndex}`}>
-                  {Array.from({ length: 7 }).map((_, cellIndex) => (
-                    <TableCell key={`skeleton-${rowIndex}-${cellIndex}`}>
-                      <Skeleton className="h-4 w-full" />
-                    </TableCell>
-                  ))}
-                </TableRow>
-              ))
-            ) : logs.length === 0 ? (
-              <TableRow>
-                <TableCell colSpan={7} className="text-center text-muted-foreground">
-                  {t("noLogs")}
-                </TableCell>
-              </TableRow>
-            ) : (
-              logs.map((log) => (
-                <TableRow key={log.id}>
-                  <TableCell className="whitespace-nowrap text-xs text-muted-foreground">
-                    {log.createdAt
-                      ? formatInTimeZone(new Date(log.createdAt), timeZone, "yyyy-MM-dd HH:mm:ss")
-                      : "-"}
-                  </TableCell>
-                  <TableCell className="space-y-1">
-                    <div className="flex items-center gap-1.5 text-sm">
-                      {log.model ? <ModelVendorIcon modelId={log.model} /> : null}
-                      {log.model ? (
-                        <span
-                          className="cursor-pointer hover:underline truncate"
-                          onClick={() => handleCopyModel(log.model!)}
-                        >
-                          {log.model}
-                        </span>
-                      ) : (
-                        <span>{t("unknownModel")}</span>
-                      )}
+    <div className="space-y-4">
+      <div className="flex items-center justify-between text-xs text-muted-foreground/70 px-1 pt-1">
+        <span>{tDashboard("logs.table.loadedCount", { count: logs.length })}</span>
+        {isFetchingNextPage ? (
+          <span className="flex items-center gap-2">
+            <Loader2 className="h-3 w-3 animate-spin" />
+            {tDashboard("logs.table.loadingMore")}
+          </span>
+        ) : !hasNextPage ? (
+          <span>{tDashboard("logs.table.noMoreData")}</span>
+        ) : null}
+      </div>
+
+      <div className="overflow-x-auto">
+        <div className="min-w-[860px] rounded-md border">
+          <div className="bg-muted/30 border-b sticky top-0 z-10">
+            <div className="flex items-center h-9 text-[11px] font-medium text-muted-foreground/80 tracking-wide">
+              <div className="flex-[1] min-w-[150px] px-3">{t("table.time")}</div>
+              <div className="flex-[2.2] min-w-[240px] px-2">{t("table.model")}</div>
+              <div className="flex-[0.9] min-w-[100px] px-2 text-right">{t("table.tokens")}</div>
+              <div className="flex-[1] min-w-[120px] px-2 text-right">{t("table.cacheWrite")}</div>
+              <div className="flex-[0.8] min-w-[90px] px-2 text-right">{t("table.cacheRead")}</div>
+              <div className="flex-[0.8] min-w-[90px] px-2 text-right">{t("table.cost")}</div>
+              <div className="flex-[0.7] min-w-[80px] px-3">{t("table.status")}</div>
+            </div>
+          </div>
+
+          <div ref={parentRef} className="h-[520px] overflow-auto" onScroll={handleScroll}>
+            <div
+              style={{
+                height: `${rowVirtualizer.getTotalSize()}px`,
+                width: "100%",
+                position: "relative",
+              }}
+            >
+              {virtualItems.map((virtualRow) => {
+                const isLoaderRow = virtualRow.index >= logs.length;
+                const log = logs[virtualRow.index];
+
+                if (isLoaderRow) {
+                  return (
+                    <div
+                      key={`loader-${virtualRow.index}`}
+                      style={{
+                        position: "absolute",
+                        top: 0,
+                        left: 0,
+                        width: "100%",
+                        height: `${virtualRow.size}px`,
+                        transform: `translateY(${virtualRow.start}px)`,
+                      }}
+                      className="flex items-center justify-center border-b"
+                    >
+                      <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
                     </div>
-                    {log.modelRedirect ? (
-                      <div className="text-xs text-muted-foreground">{log.modelRedirect}</div>
-                    ) : null}
-                    {log.billingModel && log.billingModel !== log.model ? (
-                      <div className="text-[11px] text-muted-foreground">
-                        {t("billingModel", { model: log.billingModel })}
-                      </div>
-                    ) : null}
-                  </TableCell>
-                  <TableCell className="text-right text-xs font-mono tabular-nums">
-                    <div className="flex flex-col items-end leading-tight">
-                      <span>{formatTokenAmount(log.inputTokens)}</span>
-                      <span className="text-muted-foreground">
-                        {formatTokenAmount(log.outputTokens)}
-                      </span>
+                  );
+                }
+
+                return (
+                  <div
+                    key={log.id}
+                    style={{
+                      position: "absolute",
+                      top: 0,
+                      left: 0,
+                      width: "100%",
+                      height: `${virtualRow.size}px`,
+                      transform: `translateY(${virtualRow.start}px)`,
+                    }}
+                    className="flex items-center border-b text-sm hover:bg-accent/50"
+                  >
+                    <div className="flex-[1] min-w-[150px] px-3 font-mono text-xs text-muted-foreground">
+                      {log.createdAt
+                        ? formatInTimeZone(new Date(log.createdAt), timeZone, "yyyy-MM-dd HH:mm:ss")
+                        : "-"}
                     </div>
-                  </TableCell>
-                  <TableCell className="text-right font-mono text-xs">
-                    <TooltipProvider>
-                      <Tooltip delayDuration={250}>
-                        <TooltipTrigger asChild>
-                          <div className="flex items-center gap-2 w-full cursor-help">
-                            {log.cacheCreationInputTokens &&
-                            log.cacheCreationInputTokens > 0 &&
-                            log.cacheTtlApplied ? (
-                              <Badge variant="outline" className="text-[10px] leading-tight px-1">
-                                {log.cacheTtlApplied}
-                              </Badge>
-                            ) : null}
-                            <span className="ml-auto">
-                              {formatTokenAmount(log.cacheCreationInputTokens)}
+                    <div className="flex-[2.2] min-w-[240px] px-2">
+                      <div className="space-y-1">
+                        <div className="flex items-center gap-1.5 text-sm">
+                          {log.model ? <ModelVendorIcon modelId={log.model} /> : null}
+                          {log.model ? (
+                            <span
+                              className="cursor-pointer hover:underline truncate"
+                              onClick={() => handleCopyModel(log.model!)}
+                            >
+                              {log.model}
                             </span>
+                          ) : (
+                            <span>{t("unknownModel")}</span>
+                          )}
+                        </div>
+                        {log.modelRedirect ? (
+                          <div className="text-xs text-muted-foreground truncate">
+                            {log.modelRedirect}
                           </div>
-                        </TooltipTrigger>
-                        <TooltipContent align="end" className="text-xs space-y-1">
-                          <div>5m: {formatTokenAmount(log.cacheCreation5mInputTokens)}</div>
-                          <div>1h: {formatTokenAmount(log.cacheCreation1hInputTokens)}</div>
-                        </TooltipContent>
-                      </Tooltip>
-                    </TooltipProvider>
-                  </TableCell>
-                  <TableCell className="text-right font-mono text-xs">
-                    {formatTokenAmount(log.cacheReadInputTokens)}
-                  </TableCell>
-                  <TableCell className="text-right text-sm font-mono">
-                    {CURRENCY_CONFIG[currencyCode]?.symbol ?? currencyCode}
-                    {Number(log.cost ?? 0).toFixed(4)}
-                  </TableCell>
-                  <TableCell>
-                    <Badge
-                      variant={log.statusCode && log.statusCode >= 400 ? "destructive" : "outline"}
-                      className={
-                        log.statusCode === 200
-                          ? "border-green-500 text-green-600 dark:text-green-400"
-                          : undefined
-                      }
-                    >
-                      {log.statusCode ?? "-"}
-                    </Badge>
-                  </TableCell>
-                </TableRow>
-              ))
-            )}
-          </TableBody>
-        </Table>
-      </div>
-
-      <div className="flex items-center justify-between text-sm text-muted-foreground">
-        <span>
-          {loading && loadingLabel
-            ? loadingLabel
-            : t("pagination", {
-                from: (page - 1) * pageSize + 1,
-                to: Math.min(page * pageSize, total),
-                total,
+                        ) : null}
+                        {log.billingModel && log.billingModel !== log.model ? (
+                          <div className="text-[11px] text-muted-foreground truncate">
+                            {t("billingModel", { model: log.billingModel })}
+                          </div>
+                        ) : null}
+                      </div>
+                    </div>
+                    <div className="flex-[0.9] min-w-[100px] px-2 text-right text-xs font-mono tabular-nums">
+                      <div className="flex flex-col items-end leading-tight">
+                        <span>{formatTokenAmount(log.inputTokens)}</span>
+                        <span className="text-muted-foreground">
+                          {formatTokenAmount(log.outputTokens)}
+                        </span>
+                      </div>
+                    </div>
+                    <div className="flex-[1] min-w-[120px] px-2 text-right font-mono text-xs">
+                      <TooltipProvider>
+                        <Tooltip delayDuration={250}>
+                          <TooltipTrigger asChild>
+                            <div className="flex items-center gap-2 w-full cursor-help">
+                              {log.cacheCreationInputTokens &&
+                              log.cacheCreationInputTokens > 0 &&
+                              log.cacheTtlApplied ? (
+                                <Badge variant="outline" className="text-[10px] leading-tight px-1">
+                                  {log.cacheTtlApplied}
+                                </Badge>
+                              ) : null}
+                              <span className="ml-auto">
+                                {formatTokenAmount(log.cacheCreationInputTokens)}
+                              </span>
+                            </div>
+                          </TooltipTrigger>
+                          <TooltipContent align="end" className="text-xs space-y-1">
+                            <div>5m: {formatTokenAmount(log.cacheCreation5mInputTokens)}</div>
+                            <div>1h: {formatTokenAmount(log.cacheCreation1hInputTokens)}</div>
+                          </TooltipContent>
+                        </Tooltip>
+                      </TooltipProvider>
+                    </div>
+                    <div className="flex-[0.8] min-w-[90px] px-2 text-right font-mono text-xs">
+                      {formatTokenAmount(log.cacheReadInputTokens)}
+                    </div>
+                    <div className="flex-[0.8] min-w-[90px] px-2 text-right text-sm font-mono">
+                      {CURRENCY_CONFIG[currencyCode]?.symbol ?? currencyCode}
+                      {Number(log.cost ?? 0).toFixed(4)}
+                    </div>
+                    <div className="flex-[0.7] min-w-[80px] px-3">
+                      <Badge
+                        variant={
+                          log.statusCode && log.statusCode >= 400 ? "destructive" : "outline"
+                        }
+                        className={
+                          log.statusCode === 200
+                            ? "border-green-500 text-green-600 dark:text-green-400"
+                            : undefined
+                        }
+                      >
+                        {log.statusCode ?? "-"}
+                      </Badge>
+                    </div>
+                  </div>
+                );
               })}
-        </span>
-        <div className="flex items-center gap-2">
-          <button
-            className="rounded-md border px-3 py-1 text-xs disabled:opacity-50"
-            onClick={() => onPageChange(Math.max(1, page - 1))}
-            disabled={page <= 1 || loading}
-          >
-            {t("prev")}
-          </button>
-          <span className="font-mono text-foreground">
-            {page}/{totalPages}
-          </span>
-          <button
-            className="rounded-md border px-3 py-1 text-xs disabled:opacity-50"
-            onClick={() => onPageChange(Math.min(totalPages, page + 1))}
-            disabled={page >= totalPages || loading}
-          >
-            {t("next")}
-          </button>
+            </div>
+          </div>
         </div>
       </div>
+
+      {showScrollToTop ? (
+        <Button className="fixed bottom-8 right-8 shadow-lg z-50" onClick={scrollToTop}>
+          <ArrowUp className="h-4 w-4 mr-1" />
+          {tDashboard("logs.table.scrollToTop")}
+        </Button>
+      ) : null}
     </div>
   );
 }

+ 2 - 21
src/app/[locale]/my-usage/page.tsx

@@ -1,12 +1,7 @@
 "use client";
 
 import { useCallback, useEffect, useState } from "react";
-import {
-  getMyQuota,
-  getMyUsageLogs,
-  type MyUsageLogsResult,
-  type MyUsageQuota,
-} from "@/actions/my-usage";
+import { getMyQuota, type MyUsageQuota } from "@/actions/my-usage";
 import { getServerTimeZone } from "@/actions/system-config";
 import { useRouter } from "@/i18n/routing";
 import { CollapsibleQuotaCard } from "./_components/collapsible-quota-card";
@@ -20,14 +15,11 @@ export default function MyUsagePage() {
   const router = useRouter();
 
   const [quota, setQuota] = useState<MyUsageQuota | null>(null);
-  const [logsData, setLogsData] = useState<MyUsageLogsResult | null>(null);
   const [isQuotaLoading, setIsQuotaLoading] = useState(true);
-  const [isLogsLoading, setIsLogsLoading] = useState(true);
   const [serverTimeZone, setServerTimeZone] = useState<string | undefined>(undefined);
 
   const loadInitial = useCallback(() => {
     setIsQuotaLoading(true);
-    setIsLogsLoading(true);
 
     void getMyQuota()
       .then((quotaResult) => {
@@ -35,12 +27,6 @@ export default function MyUsagePage() {
       })
       .finally(() => setIsQuotaLoading(false));
 
-    void getMyUsageLogs({ page: 1 })
-      .then((logsResult) => {
-        if (logsResult.ok) setLogsData(logsResult.data ?? null);
-      })
-      .finally(() => setIsLogsLoading(false));
-
     void getServerTimeZone().then((tzResult) => {
       if (tzResult.ok) setServerTimeZone(tzResult.data.timeZone);
     });
@@ -85,12 +71,7 @@ export default function MyUsagePage() {
 
       <StatisticsSummaryCard serverTimeZone={serverTimeZone} />
 
-      <UsageLogsSection
-        initialData={logsData}
-        loading={isLogsLoading}
-        autoRefreshSeconds={30}
-        serverTimeZone={serverTimeZone}
-      />
+      <UsageLogsSection autoRefreshSeconds={30} serverTimeZone={serverTimeZone} />
     </div>
   );
 }

+ 21 - 11
src/app/api/actions/[...route]/route.ts

@@ -1040,21 +1040,27 @@ const { route: getMyTodayStatsRoute, handler: getMyTodayStatsHandler } = createA
 );
 app.openapi(getMyTodayStatsRoute, getMyTodayStatsHandler);
 
-const { route: getMyUsageLogsRoute, handler: getMyUsageLogsHandler } = createActionRoute(
+const { route: getMyUsageLogsBatchRoute, handler: getMyUsageLogsBatchHandler } = createActionRoute(
   "my-usage",
-  "getMyUsageLogs",
-  myUsageActions.getMyUsageLogs,
+  "getMyUsageLogsBatch",
+  myUsageActions.getMyUsageLogsBatch,
   {
     requestSchema: z.object({
       startDate: z.string().optional().describe("开始日期(YYYY-MM-DD,可为空)"),
       endDate: z.string().optional().describe("结束日期(YYYY-MM-DD,可为空)"),
+      sessionId: z.string().optional(),
       model: z.string().optional(),
       endpoint: z.string().optional(),
       statusCode: z.number().optional(),
       excludeStatusCode200: z.boolean().optional(),
       minRetryCount: z.number().int().nonnegative().optional(),
-      pageSize: z.number().int().positive().max(100).default(20).optional(),
-      page: z.number().int().positive().default(1).optional(),
+      cursor: z
+        .object({
+          createdAt: z.string(),
+          id: z.number(),
+        })
+        .optional(),
+      limit: z.number().int().positive().max(100).default(20).optional(),
     }),
     responseSchema: z.object({
       logs: z.array(
@@ -1077,19 +1083,23 @@ const { route: getMyUsageLogsRoute, handler: getMyUsageLogsHandler } = createAct
           cacheTtlApplied: z.string().nullable(),
         })
       ),
-      total: z.number(),
-      page: z.number(),
-      pageSize: z.number(),
+      nextCursor: z
+        .object({
+          createdAt: z.string(),
+          id: z.number(),
+        })
+        .nullable(),
+      hasMore: z.boolean(),
       currencyCode: z.string(),
       billingModelSource: z.enum(["original", "redirected"]),
     }),
-    description: "获取当前会话的使用日志(仅返回自己的数据)",
-    summary: "获取我的使用日志",
+    description: "获取当前会话的使用日志批量数据(仅返回自己的数据,游标分页)",
+    summary: "批量获取我的使用日志",
     tags: ["使用日志"],
     allowReadOnlyAccess: true,
   }
 );
-app.openapi(getMyUsageLogsRoute, getMyUsageLogsHandler);
+app.openapi(getMyUsageLogsBatchRoute, getMyUsageLogsBatchHandler);
 
 const { route: getMyAvailableModelsRoute, handler: getMyAvailableModelsHandler } =
   createActionRoute("my-usage", "getMyAvailableModels", myUsageActions.getMyAvailableModels, {

+ 81 - 0
src/hooks/use-virtualized-infinite-list.ts

@@ -0,0 +1,81 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useVirtualizer } from "@/hooks/use-virtualizer";
+
+interface UseVirtualizedInfiniteListOptions {
+  itemCount: number;
+  hasNextPage: boolean;
+  isFetchingNextPage: boolean;
+  fetchNextPage: () => Promise<unknown> | undefined;
+  estimateSize: (index: number) => number;
+  overscan?: number;
+  loadMoreThreshold?: number;
+  scrollTopThreshold?: number;
+  getItemKey?: (index: number) => string | number;
+}
+
+export function useVirtualizedInfiniteList({
+  itemCount,
+  hasNextPage,
+  isFetchingNextPage,
+  fetchNextPage,
+  estimateSize,
+  overscan = 10,
+  loadMoreThreshold = 5,
+  scrollTopThreshold = 500,
+  getItemKey,
+}: UseVirtualizedInfiniteListOptions) {
+  const parentRef = useRef<HTMLDivElement>(null);
+  const [showScrollToTop, setShowScrollToTop] = useState(false);
+
+  const getScrollElement = useCallback(() => parentRef.current, []);
+
+  const rowVirtualizer = useVirtualizer({
+    count: hasNextPage ? itemCount + 1 : itemCount,
+    getScrollElement,
+    estimateSize,
+    overscan,
+    getItemKey,
+  });
+  const rowVirtualizerRef = useRef(rowVirtualizer);
+  rowVirtualizerRef.current = rowVirtualizer;
+
+  const virtualItems = rowVirtualizer.getVirtualItems();
+  const lastItemIndex = virtualItems[virtualItems.length - 1]?.index ?? -1;
+
+  useEffect(() => {
+    if (itemCount === 0) return;
+    if (!hasNextPage) return;
+    if (isFetchingNextPage) return;
+    if (lastItemIndex >= itemCount - loadMoreThreshold) {
+      void fetchNextPage();
+    }
+  }, [lastItemIndex, itemCount, hasNextPage, isFetchingNextPage, loadMoreThreshold, fetchNextPage]);
+
+  const handleScroll = useCallback(() => {
+    setShowScrollToTop((parentRef.current?.scrollTop ?? 0) > scrollTopThreshold);
+  }, [scrollTopThreshold]);
+
+  const scrollToTop = useCallback(() => {
+    parentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
+  }, []);
+
+  const resetScrollPosition = useCallback(() => {
+    rowVirtualizerRef.current.scrollToOffset?.(0);
+    if (parentRef.current) {
+      parentRef.current.scrollTop = 0;
+    }
+    setShowScrollToTop(false);
+  }, []);
+
+  return {
+    parentRef,
+    rowVirtualizer,
+    virtualItems,
+    showScrollToTop,
+    handleScroll,
+    scrollToTop,
+    resetScrollPosition,
+  };
+}

+ 117 - 152
src/repository/usage-logs.ts

@@ -419,8 +419,11 @@ interface UsageLogSlimFilters {
   endpoint?: string;
   /** 最低重试次数(按 provider_chain 中“实际请求”数量 - 1 计算;<= 0 视为不筛选) */
   minRetryCount?: number;
-  page?: number;
-  pageSize?: number;
+}
+
+interface UsageLogSlimBatchFilters extends UsageLogSlimFilters {
+  cursor?: { createdAt: string; id: number };
+  limit?: number;
 }
 
 interface UsageLogSlimRow {
@@ -442,6 +445,12 @@ interface UsageLogSlimRow {
   anthropicEffort?: string | null;
 }
 
+export interface UsageLogSlimBatchResult {
+  logs: UsageLogSlimRow[];
+  nextCursor: { createdAt: string; id: number } | null;
+  hasMore: boolean;
+}
+
 function mapUsageLogSlimRow(row: {
   id: number;
   createdAt: Date | null;
@@ -478,16 +487,11 @@ function mapUsageLogSlimRow(row: {
   };
 }
 
-// my-usage logs: short TTL cache for total count to avoid repeated COUNT(*) on pagination/polling.
-const usageLogSlimTotalCache = new TTLMap<string, number>({ ttlMs: 10_000, maxSize: 1000 });
-
-export async function findUsageLogsForKeySlim(
-  filters: UsageLogSlimFilters
-): Promise<{ logs: UsageLogSlimRow[]; total: number }> {
-  const { keyString, page = 1, pageSize = 50 } = filters;
-
-  const safePage = page > 0 ? page : 1;
-  const safePageSize = Math.min(100, Math.max(1, pageSize));
+export async function findUsageLogsForKeyBatch(
+  filters: UsageLogSlimBatchFilters
+): Promise<UsageLogSlimBatchResult> {
+  const { keyString, cursor, limit = 20 } = filters;
+  const safeLimit = Math.min(100, Math.max(1, limit));
 
   const conditions = [
     isNull(messageRequest.deletedAt),
@@ -495,25 +499,21 @@ export async function findUsageLogsForKeySlim(
     EXCLUDE_WARMUP_CONDITION,
   ];
 
-  const totalCacheKey = [
-    keyString,
-    filters.sessionId?.trim() ?? "",
-    filters.startTime ?? "",
-    filters.endTime ?? "",
-    filters.statusCode ?? "",
-    filters.excludeStatusCode200 ? "1" : "0",
-    filters.model ?? "",
-    filters.endpoint ?? "",
-    filters.minRetryCount ?? "",
-  ].join("\u0001");
-
   conditions.push(...buildUsageLogConditions(filters));
 
-  const offset = (safePage - 1) * safePageSize;
+  if (cursor) {
+    conditions.push(
+      sql`(${messageRequest.createdAt}, ${messageRequest.id}) < (${cursor.createdAt}::timestamptz, ${cursor.id})`
+    );
+  }
+
+  const fetchLimit = safeLimit + 1;
+
   const results = await db
     .select({
       id: messageRequest.id,
       createdAt: messageRequest.createdAt,
+      createdAtRaw: sql<string>`to_char(${messageRequest.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
       model: messageRequest.model,
       originalModel: messageRequest.originalModel,
       endpoint: messageRequest.endpoint,
@@ -532,143 +532,108 @@ export async function findUsageLogsForKeySlim(
     .from(messageRequest)
     .where(and(...conditions))
     .orderBy(desc(messageRequest.createdAt), desc(messageRequest.id))
-    .limit(safePageSize + 1)
-    .offset(offset);
+    .limit(fetchLimit);
 
-  const hasMore = results.length > safePageSize;
-  const pageRows = hasMore ? results.slice(0, safePageSize) : results;
-
-  if (pageRows.length === 0 && (await isLedgerOnlyMode())) {
-    if (filters.minRetryCount !== undefined && filters.minRetryCount > 0) {
-      return { logs: [], total: 0 };
-    }
-
-    const ledgerConditions = [LEDGER_BILLING_CONDITION, eq(usageLedger.key, keyString)];
-
-    const trimmedSessionId = filters.sessionId?.trim();
-    if (trimmedSessionId) {
-      ledgerConditions.push(eq(usageLedger.sessionId, trimmedSessionId));
-    }
-
-    if (filters.startTime) {
-      ledgerConditions.push(gte(usageLedger.createdAt, new Date(filters.startTime)));
-    }
-
-    if (filters.endTime) {
-      ledgerConditions.push(lt(usageLedger.createdAt, new Date(filters.endTime)));
-    }
-
-    if (filters.statusCode !== undefined) {
-      ledgerConditions.push(eq(usageLedger.statusCode, filters.statusCode));
-    } else if (filters.excludeStatusCode200) {
-      ledgerConditions.push(
-        sql`(${usageLedger.statusCode} IS NULL OR ${usageLedger.statusCode} <> 200)`
-      );
-    }
-
-    if (filters.model) {
-      ledgerConditions.push(eq(usageLedger.model, filters.model));
-    }
-
-    if (filters.endpoint) {
-      ledgerConditions.push(eq(usageLedger.endpoint, filters.endpoint));
-    }
-
-    const ledgerResults = await db
-      .select({
-        id: usageLedger.requestId,
-        createdAt: usageLedger.createdAt,
-        model: usageLedger.model,
-        originalModel: usageLedger.originalModel,
-        endpoint: usageLedger.endpoint,
-        statusCode: usageLedger.statusCode,
-        inputTokens: usageLedger.inputTokens,
-        outputTokens: usageLedger.outputTokens,
-        costUsd: usageLedger.costUsd,
-        durationMs: usageLedger.durationMs,
-        cacheCreationInputTokens: usageLedger.cacheCreationInputTokens,
-        cacheReadInputTokens: usageLedger.cacheReadInputTokens,
-        cacheCreation5mInputTokens: usageLedger.cacheCreation5mInputTokens,
-        cacheCreation1hInputTokens: usageLedger.cacheCreation1hInputTokens,
-        cacheTtlApplied: usageLedger.cacheTtlApplied,
-      })
-      .from(usageLedger)
-      .where(and(...ledgerConditions))
-      .orderBy(desc(usageLedger.createdAt), desc(usageLedger.requestId))
-      .limit(safePageSize + 1)
-      .offset(offset);
-
-    const ledgerHasMore = ledgerResults.length > safePageSize;
-    const ledgerPageRows = ledgerHasMore ? ledgerResults.slice(0, safePageSize) : ledgerResults;
-
-    let ledgerTotal = offset + ledgerPageRows.length;
-
-    const cachedTotal = usageLogSlimTotalCache.get(totalCacheKey);
-    if (cachedTotal !== undefined) {
-      ledgerTotal = Math.max(cachedTotal, ledgerTotal);
-      return {
-        logs: ledgerPageRows.map((row) => ({
-          ...row,
-          costUsd: row.costUsd?.toString() ?? null,
-          anthropicEffort: null,
-        })),
-        total: ledgerTotal,
-      };
-    }
-
-    if (ledgerPageRows.length === 0 && offset > 0) {
-      const countResults = await db
-        .select({ totalRows: sql<number>`count(*)::double precision` })
-        .from(usageLedger)
-        .where(and(...ledgerConditions));
-      ledgerTotal = countResults[0]?.totalRows ?? 0;
-    } else if (ledgerHasMore) {
-      const countResults = await db
-        .select({ totalRows: sql<number>`count(*)::double precision` })
-        .from(usageLedger)
-        .where(and(...ledgerConditions));
-      ledgerTotal = countResults[0]?.totalRows ?? 0;
-    }
-
-    const ledgerLogs: UsageLogSlimRow[] = ledgerPageRows.map((row) => ({
-      ...row,
-      costUsd: row.costUsd?.toString() ?? null,
-      anthropicEffort: null,
-    }));
+  const hasMore = results.length > safeLimit;
+  const rowsToReturn = hasMore ? results.slice(0, safeLimit) : results;
+  const lastRow = rowsToReturn[rowsToReturn.length - 1];
+  const nextCursor =
+    hasMore && lastRow?.createdAtRaw ? { createdAt: lastRow.createdAtRaw, id: lastRow.id } : null;
 
-    usageLogSlimTotalCache.set(totalCacheKey, ledgerTotal);
-    return { logs: ledgerLogs, total: ledgerTotal };
+  if (rowsToReturn.length > 0) {
+    return {
+      logs: rowsToReturn.map((row) => mapUsageLogSlimRow(row)),
+      nextCursor,
+      hasMore,
+    };
   }
 
-  let total = offset + pageRows.length;
+  if (!(await isLedgerOnlyMode())) {
+    return { logs: [], nextCursor, hasMore };
+  }
 
-  const cachedTotal = usageLogSlimTotalCache.get(totalCacheKey);
-  if (cachedTotal !== undefined) {
-    total = Math.max(cachedTotal, total);
-    return {
-      logs: pageRows.map((row) => mapUsageLogSlimRow(row)),
-      total,
-    };
+  if (filters.minRetryCount !== undefined && filters.minRetryCount > 0) {
+    return { logs: [], nextCursor: null, hasMore: false };
+  }
+
+  const ledgerConditions = [LEDGER_BILLING_CONDITION, eq(usageLedger.key, keyString)];
+
+  const trimmedSessionId = filters.sessionId?.trim();
+  if (trimmedSessionId) {
+    ledgerConditions.push(eq(usageLedger.sessionId, trimmedSessionId));
+  }
+
+  if (filters.startTime) {
+    ledgerConditions.push(gte(usageLedger.createdAt, new Date(filters.startTime)));
+  }
+
+  if (filters.endTime) {
+    ledgerConditions.push(lt(usageLedger.createdAt, new Date(filters.endTime)));
   }
 
-  if (pageRows.length === 0 && offset > 0) {
-    const countResults = await db
-      .select({ totalRows: sql<number>`count(*)::double precision` })
-      .from(messageRequest)
-      .where(and(...conditions));
-    total = countResults[0]?.totalRows ?? 0;
-  } else if (hasMore) {
-    const countResults = await db
-      .select({ totalRows: sql<number>`count(*)::double precision` })
-      .from(messageRequest)
-      .where(and(...conditions));
-    total = countResults[0]?.totalRows ?? 0;
+  if (filters.statusCode !== undefined) {
+    ledgerConditions.push(eq(usageLedger.statusCode, filters.statusCode));
+  } else if (filters.excludeStatusCode200) {
+    ledgerConditions.push(
+      sql`(${usageLedger.statusCode} IS NULL OR ${usageLedger.statusCode} <> 200)`
+    );
+  }
+
+  if (filters.model) {
+    ledgerConditions.push(eq(usageLedger.model, filters.model));
+  }
+
+  if (filters.endpoint) {
+    ledgerConditions.push(eq(usageLedger.endpoint, filters.endpoint));
   }
 
-  const logs: UsageLogSlimRow[] = pageRows.map((row) => mapUsageLogSlimRow(row));
+  if (cursor) {
+    ledgerConditions.push(
+      sql`(${usageLedger.createdAt}, ${usageLedger.requestId}) < (${cursor.createdAt}::timestamptz, ${cursor.id})`
+    );
+  }
 
-  usageLogSlimTotalCache.set(totalCacheKey, total);
-  return { logs, total };
+  const ledgerResults = await db
+    .select({
+      id: usageLedger.requestId,
+      createdAt: usageLedger.createdAt,
+      createdAtRaw: sql<string>`to_char(${usageLedger.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`,
+      model: usageLedger.model,
+      originalModel: usageLedger.originalModel,
+      endpoint: usageLedger.endpoint,
+      statusCode: usageLedger.statusCode,
+      inputTokens: usageLedger.inputTokens,
+      outputTokens: usageLedger.outputTokens,
+      costUsd: usageLedger.costUsd,
+      durationMs: usageLedger.durationMs,
+      cacheCreationInputTokens: usageLedger.cacheCreationInputTokens,
+      cacheReadInputTokens: usageLedger.cacheReadInputTokens,
+      cacheCreation5mInputTokens: usageLedger.cacheCreation5mInputTokens,
+      cacheCreation1hInputTokens: usageLedger.cacheCreation1hInputTokens,
+      cacheTtlApplied: usageLedger.cacheTtlApplied,
+    })
+    .from(usageLedger)
+    .where(and(...ledgerConditions))
+    .orderBy(desc(usageLedger.createdAt), desc(usageLedger.requestId))
+    .limit(fetchLimit);
+
+  const ledgerHasMore = ledgerResults.length > safeLimit;
+  const ledgerRowsToReturn = ledgerHasMore ? ledgerResults.slice(0, safeLimit) : ledgerResults;
+  const ledgerLastRow = ledgerRowsToReturn[ledgerRowsToReturn.length - 1];
+  const ledgerNextCursor =
+    ledgerHasMore && ledgerLastRow?.createdAtRaw
+      ? { createdAt: ledgerLastRow.createdAtRaw, id: ledgerLastRow.id }
+      : null;
+
+  return {
+    logs: ledgerRowsToReturn.map((row) => ({
+      ...row,
+      costUsd: row.costUsd?.toString() ?? null,
+      anthropicEffort: null,
+    })),
+    nextCursor: ledgerNextCursor,
+    hasMore: ledgerHasMore,
+  };
 }
 
 const distinctModelsByKeyCache = new TTLMap<string, string[]>({

+ 1 - 1
tests/api/api-actions-integrity.test.ts

@@ -118,7 +118,7 @@ describe("OpenAPI 端点完整性检查", () => {
       "/api/actions/my-usage/getMyUsageMetadata",
       "/api/actions/my-usage/getMyQuota",
       "/api/actions/my-usage/getMyTodayStats",
-      "/api/actions/my-usage/getMyUsageLogs",
+      "/api/actions/my-usage/getMyUsageLogsBatch",
       "/api/actions/my-usage/getMyAvailableModels",
       "/api/actions/my-usage/getMyAvailableEndpoints",
     ];

+ 2 - 2
tests/api/my-usage-readonly.test.ts

@@ -406,9 +406,9 @@ describe("my-usage API:只读 Key 自助查询", () => {
     // 同时验证 usage logs:不应返回 B 的日志(不泄漏)
     const logs = await callActionsRoute({
       method: "POST",
-      pathname: "/api/actions/my-usage/getMyUsageLogs",
+      pathname: "/api/actions/my-usage/getMyUsageLogsBatch",
       authToken: keyA.key,
-      body: { page: 1, pageSize: 50 },
+      body: { limit: 50 },
     });
 
     expect(logs.response.status).toBe(200);

+ 24 - 12
tests/unit/actions/my-usage-date-range-dst.test.ts

@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
   getSession: vi.fn(),
   getSystemSettings: vi.fn(),
   findUsageLogsForKeySlim: vi.fn(),
+  findUsageLogsForKeyBatch: vi.fn(),
   resolveSystemTimezone: vi.fn(),
 }));
 
@@ -21,6 +22,7 @@ vi.mock("@/repository/usage-logs", async (importOriginal) => {
   return {
     ...actual,
     findUsageLogsForKeySlim: mocks.findUsageLogsForKeySlim,
+    findUsageLogsForKeyBatch: mocks.findUsageLogsForKeyBatch,
   };
 });
 
@@ -29,7 +31,7 @@ vi.mock("@/lib/utils/timezone", () => ({
 }));
 
 describe("my-usage date range parsing", () => {
-  it("computes exclusive endTime as next local midnight across DST start", async () => {
+  it("computes exclusive endTime as next local midnight across DST start for batch logs", async () => {
     const tz = "America/Los_Angeles";
     mocks.resolveSystemTimezone.mockResolvedValue(tz);
 
@@ -43,22 +45,27 @@ describe("my-usage date range parsing", () => {
       billingModelSource: "original",
     });
 
-    mocks.findUsageLogsForKeySlim.mockResolvedValue({ logs: [], total: 0 });
+    mocks.findUsageLogsForKeyBatch.mockResolvedValue({
+      logs: [],
+      nextCursor: null,
+      hasMore: false,
+    });
 
-    const { getMyUsageLogs } = await import("@/actions/my-usage");
-    const res = await getMyUsageLogs({ startDate: "2024-03-10", endDate: "2024-03-10" });
+    const { getMyUsageLogsBatch } = await import("@/actions/my-usage");
+    const res = await getMyUsageLogsBatch({ startDate: "2024-03-10", endDate: "2024-03-10" });
 
     expect(res.ok).toBe(true);
-    expect(mocks.findUsageLogsForKeySlim).toHaveBeenCalledTimes(1);
+    expect(mocks.findUsageLogsForKeyBatch).toHaveBeenCalledTimes(1);
 
-    const args = mocks.findUsageLogsForKeySlim.mock.calls[0]?.[0];
+    const args = mocks.findUsageLogsForKeyBatch.mock.calls[0]?.[0];
     expect(args.startTime).toBe(fromZonedTime("2024-03-10T00:00:00", tz).getTime());
     expect(args.endTime).toBe(fromZonedTime("2024-03-11T00:00:00", tz).getTime());
+    expect(args.limit).toBe(20);
 
     expect(args.endTime - args.startTime).toBe(23 * 60 * 60 * 1000);
   });
 
-  it("computes exclusive endTime as next local midnight across DST end", async () => {
+  it("computes exclusive endTime as next local midnight across DST end for batch logs", async () => {
     const tz = "America/Los_Angeles";
     mocks.resolveSystemTimezone.mockResolvedValue(tz);
 
@@ -72,17 +79,22 @@ describe("my-usage date range parsing", () => {
       billingModelSource: "original",
     });
 
-    mocks.findUsageLogsForKeySlim.mockResolvedValue({ logs: [], total: 0 });
+    mocks.findUsageLogsForKeyBatch.mockResolvedValue({
+      logs: [],
+      nextCursor: null,
+      hasMore: false,
+    });
 
-    const { getMyUsageLogs } = await import("@/actions/my-usage");
-    const res = await getMyUsageLogs({ startDate: "2024-11-03", endDate: "2024-11-03" });
+    const { getMyUsageLogsBatch } = await import("@/actions/my-usage");
+    const res = await getMyUsageLogsBatch({ startDate: "2024-11-03", endDate: "2024-11-03" });
 
     expect(res.ok).toBe(true);
-    expect(mocks.findUsageLogsForKeySlim).toHaveBeenCalledTimes(1);
+    expect(mocks.findUsageLogsForKeyBatch).toHaveBeenCalledTimes(1);
 
-    const args = mocks.findUsageLogsForKeySlim.mock.calls[0]?.[0];
+    const args = mocks.findUsageLogsForKeyBatch.mock.calls[0]?.[0];
     expect(args.startTime).toBe(fromZonedTime("2024-11-03T00:00:00", tz).getTime());
     expect(args.endTime).toBe(fromZonedTime("2024-11-04T00:00:00", tz).getTime());
+    expect(args.limit).toBe(20);
 
     expect(args.endTime - args.startTime).toBe(25 * 60 * 60 * 1000);
   });