Просмотр исходного кода

Fix/logs provider badge overflow (#581)

* fix: prevent provider badge overflow in logs table

* fix: make provider chain trigger shrinkable

* fix: hide invalid cost multiplier badge

* fix: avoid invalid multiplier in usage logs

* fix: hide invalid multiplier in error details

* test: add provider chain popover layout regression

* test: cover invalid multiplier rendering branches

* test: cover empty and undefined cost multiplier
YangQing-Lin 1 месяц назад
Родитель
Сommit
9901cffda7

+ 375 - 0
src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx

@@ -1,5 +1,7 @@
 import type { ReactNode } from "react";
 import { renderToStaticMarkup } from "react-dom/server";
+import { createRoot } from "react-dom/client";
+import { act } from "react";
 import { NextIntlClientProvider } from "next-intl";
 import { Window } from "happy-dom";
 import { describe, expect, test, vi } from "vitest";
@@ -55,6 +57,10 @@ vi.mock("@/components/ui/dialog", () => {
   };
 });
 
+vi.mock("@/lib/utils/provider-chain-formatter", () => ({
+  formatProviderTimeline: () => ({ timeline: "timeline", totalDuration: 123 }),
+}));
+
 import { ErrorDetailsDialog } from "./error-details-dialog";
 
 const messages = {
@@ -70,6 +76,28 @@ const messages = {
         processing: "Processing",
         success: "Success",
         error: "Error",
+        skipped: {
+          title: "Skipped",
+          reason: "Reason",
+          warmup: "Warmup",
+          desc: "Warmup skipped",
+        },
+        blocked: {
+          title: "Blocked",
+          type: "Type",
+          sensitiveWord: "Sensitive word",
+          word: "Word",
+          matchType: "Match type",
+          matchTypeContains: "Contains",
+          matchTypeExact: "Exact",
+          matchTypeRegex: "Regex",
+          matchedText: "Matched text",
+        },
+        modelRedirect: {
+          title: "Model redirect",
+          billingOriginal: "Billing original",
+          billingRedirected: "Billing redirected",
+        },
         specialSettings: {
           title: "Special settings",
         },
@@ -87,6 +115,16 @@ const messages = {
           success: "No error (success)",
           default: "No error",
         },
+        errorMessage: "Error message",
+        filteredProviders: "Filtered providers",
+        providerChain: {
+          title: "Provider chain",
+          totalDuration: "Total duration: {duration}",
+        },
+        reasons: {
+          rateLimited: "Rate limited",
+          circuitOpen: "Circuit open",
+        },
       },
       billingDetails: {
         input: "Input",
@@ -280,4 +318,341 @@ describe("error-details-dialog layout", () => {
       "md:grid-cols-2"
     );
   });
+
+  test("uses gray status class for unexpected statusCode (e.g., 100)", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={100}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+      />
+    );
+
+    expect(html).toContain("bg-gray-100");
+  });
+
+  test("covers 3xx and 4xx status badge classes", () => {
+    const html3xx = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={302}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+      />
+    );
+    expect(html3xx).toContain("bg-blue-100");
+
+    const html4xx = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={404}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+      />
+    );
+    expect(html4xx).toContain("bg-yellow-100");
+  });
+
+  test("covers in-progress state when statusCode is null", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={null}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+      />
+    );
+
+    expect(html).toContain("In progress");
+    expect(html).toContain("Processing");
+  });
+
+  test("renders filtered providers and provider chain timeline when present", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={200}
+        errorMessage={null}
+        sessionId={null}
+        providerChain={
+          [
+            {
+              id: 1,
+              name: "p1",
+              reason: "request_success",
+              statusCode: 200,
+              decisionContext: {
+                filteredProviders: [
+                  {
+                    id: 2,
+                    name: "filtered-provider",
+                    reason: "rate_limited",
+                    details: "$1",
+                  },
+                ],
+              },
+            },
+          ] as any
+        }
+      />
+    );
+
+    expect(html).toContain("Filtered providers");
+    expect(html).toContain("filtered-provider");
+    expect(html).toContain("Provider chain");
+    expect(html).toContain("timeline");
+    expect(html).toContain("Total duration");
+  });
+
+  test("formats JSON rate limit error message and filtered providers", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={429}
+        errorMessage={JSON.stringify({
+          code: "rate_limit_exceeded",
+          message: "Rate limited",
+          details: {
+            filteredProviders: [{ id: 1, name: "p", reason: "rate_limited", details: "$1" }],
+          },
+        })}
+        providerChain={null}
+        sessionId={null}
+      />
+    );
+
+    expect(html).toContain("Error message");
+    expect(html).toContain("Rate limited");
+    expect(html).toContain("p");
+    expect(html).toContain("$1");
+  });
+
+  test("formats non-rate-limit JSON error as pretty JSON", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={500}
+        errorMessage={JSON.stringify({ error: "E", code: "other" })}
+        providerChain={null}
+        sessionId={null}
+      />
+    );
+
+    expect(html).toContain("Error message");
+    expect(html).toContain("&quot;error&quot;");
+  });
+
+  test("falls back to raw error message when it is not JSON", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={500}
+        errorMessage={"not-json"}
+        providerChain={null}
+        sessionId={null}
+      />
+    );
+
+    expect(html).toContain("Error message");
+    expect(html).toContain("not-json");
+  });
+
+  test("renders warmup skipped and blocked sections when applicable", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={200}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+        blockedBy={"warmup"}
+      />
+    );
+    expect(html).toContain("Skipped");
+    expect(html).toContain("Warmup");
+
+    const htmlBlocked = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={200}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+        blockedBy={"sensitive_word"}
+        blockedReason={JSON.stringify({ word: "bad", matchType: "contains", matchedText: "bad" })}
+      />
+    );
+    expect(htmlBlocked).toContain("Blocked");
+    expect(htmlBlocked).toContain("Sensitive word");
+    expect(htmlBlocked).toContain("bad");
+    expect(htmlBlocked).toContain("Contains");
+  });
+
+  test("renders model redirect section when originalModel != currentModel", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={200}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+        originalModel={"m1"}
+        currentModel={"m2"}
+        billingModelSource={"original"}
+      />
+    );
+
+    expect(html).toContain("Model redirect");
+    expect(html).toContain("m1");
+    expect(html).toContain("m2");
+    expect(html).toContain("Billing original");
+  });
+
+  test("scrolls to model redirect section when scrollToRedirect is true", async () => {
+    vi.useFakeTimers();
+    const container = document.createElement("div");
+    document.body.appendChild(container);
+
+    const scrollIntoViewMock = vi.fn();
+    const originalScrollIntoView = Element.prototype.scrollIntoView;
+    Object.defineProperty(Element.prototype, "scrollIntoView", {
+      value: scrollIntoViewMock,
+      configurable: true,
+    });
+
+    const root = createRoot(container);
+    await act(async () => {
+      root.render(
+        <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
+          <ErrorDetailsDialog
+            externalOpen
+            statusCode={200}
+            errorMessage={null}
+            providerChain={null}
+            sessionId={null}
+            scrollToRedirect
+            originalModel={"m1"}
+            currentModel={"m2"}
+          />
+        </NextIntlClientProvider>
+      );
+    });
+
+    await act(async () => {
+      vi.advanceTimersByTime(150);
+    });
+
+    expect(scrollIntoViewMock).toHaveBeenCalled();
+
+    await act(async () => {
+      root.unmount();
+    });
+
+    Object.defineProperty(Element.prototype, "scrollIntoView", {
+      value: originalScrollIntoView,
+      configurable: true,
+    });
+    vi.useRealTimers();
+    container.remove();
+  });
+});
+
+describe("error-details-dialog multiplier", () => {
+  test("does not render multiplier row when costMultiplier is empty string", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={500}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+        costUsd={"0.000001"}
+        costMultiplier={""}
+        inputTokens={100}
+        outputTokens={80}
+      />
+    );
+
+    expect(html).not.toContain("Multiplier");
+  });
+
+  test("does not render multiplier row when costMultiplier is undefined", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={500}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+        costUsd={"0.000001"}
+        costMultiplier={undefined}
+        inputTokens={100}
+        outputTokens={80}
+      />
+    );
+
+    expect(html).not.toContain("Multiplier");
+  });
+
+  test("does not render multiplier row when costMultiplier is NaN", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={500}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+        costUsd={"0.000001"}
+        costMultiplier={"NaN"}
+        inputTokens={100}
+        outputTokens={80}
+      />
+    );
+
+    expect(html).not.toContain("Multiplier");
+    expect(html).not.toContain("NaN");
+  });
+
+  test("does not render multiplier row when costMultiplier is Infinity", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={500}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+        costUsd={"0.000001"}
+        costMultiplier={"Infinity"}
+        inputTokens={100}
+        outputTokens={80}
+      />
+    );
+
+    expect(html).not.toContain("Multiplier");
+    expect(html).not.toContain("Infinity");
+  });
+
+  test("renders multiplier row when costMultiplier is finite and != 1", () => {
+    const html = renderWithIntl(
+      <ErrorDetailsDialog
+        externalOpen
+        statusCode={500}
+        errorMessage={null}
+        providerChain={null}
+        sessionId={null}
+        costUsd={"0.000001"}
+        costMultiplier={"0.2"}
+        inputTokens={100}
+        outputTokens={80}
+      />
+    );
+
+    expect(html).toContain("Multiplier");
+    expect(html).toContain("0.20x");
+  });
 });

+ 14 - 10
src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx

@@ -536,16 +536,20 @@ export function ErrorDetailsDialog({
                             </div>
                           </div>
                         )}
-                        {costMultiplier && parseFloat(String(costMultiplier)) !== 1.0 && (
-                          <div className="flex justify-between">
-                            <span className="text-muted-foreground">
-                              {t("logs.billingDetails.multiplier")}:
-                            </span>
-                            <span className="font-mono">
-                              {parseFloat(String(costMultiplier)).toFixed(2)}x
-                            </span>
-                          </div>
-                        )}
+                        {(() => {
+                          if (costMultiplier === "" || costMultiplier == null) return null;
+                          const multiplier = Number(costMultiplier);
+                          if (!Number.isFinite(multiplier) || multiplier === 1) return null;
+
+                          return (
+                            <div className="flex justify-between">
+                              <span className="text-muted-foreground">
+                                {t("logs.billingDetails.multiplier")}:
+                              </span>
+                              <span className="font-mono">{multiplier.toFixed(2)}x</span>
+                            </div>
+                          );
+                        })()}
                       </div>
                       <div className="mt-3 pt-3 border-t flex justify-between items-center">
                         <span className="font-medium">{t("logs.billingDetails.totalCost")}:</span>

+ 149 - 0
src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx

@@ -0,0 +1,149 @@
+import type { ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { NextIntlClientProvider } from "next-intl";
+import { Window } from "happy-dom";
+import { describe, expect, test, vi } from "vitest";
+
+vi.mock("@/lib/utils/provider-chain-formatter", () => ({
+  formatProviderDescription: () => "provider description",
+}));
+
+vi.mock("@/components/ui/tooltip", () => {
+  type PropsWithChildren = { children?: ReactNode };
+
+  function TooltipProvider({ children }: PropsWithChildren) {
+    return <div data-slot="tooltip-provider">{children}</div>;
+  }
+
+  function Tooltip({ children }: PropsWithChildren) {
+    return <div data-slot="tooltip-root">{children}</div>;
+  }
+
+  function TooltipTrigger({ children }: PropsWithChildren) {
+    return <div data-slot="tooltip-trigger">{children}</div>;
+  }
+
+  function TooltipContent({ children }: PropsWithChildren) {
+    return <div data-slot="tooltip-content">{children}</div>;
+  }
+
+  return { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent };
+});
+
+vi.mock("@/components/ui/popover", () => {
+  type PropsWithChildren = { children?: ReactNode };
+
+  function Popover({ children }: PropsWithChildren) {
+    return <div data-slot="popover-root">{children}</div>;
+  }
+
+  function PopoverTrigger({ children }: PropsWithChildren) {
+    return <div data-slot="popover-trigger">{children}</div>;
+  }
+
+  function PopoverContent({ children }: PropsWithChildren) {
+    return <div data-slot="popover-content">{children}</div>;
+  }
+
+  return { Popover, PopoverTrigger, PopoverContent };
+});
+
+vi.mock("@/components/ui/button", () => ({
+  Button: ({
+    children,
+    className,
+    ...props
+  }: React.ComponentProps<"button"> & { variant?: string }) => (
+    <button className={className} {...props}>
+      {children}
+    </button>
+  ),
+}));
+
+vi.mock("@/components/ui/badge", () => ({
+  Badge: ({ children, className }: React.ComponentProps<"span"> & { variant?: string }) => (
+    <span data-slot="badge" className={className}>
+      {children}
+    </span>
+  ),
+}));
+
+import { ProviderChainPopover } from "./provider-chain-popover";
+
+const messages = {
+  dashboard: {
+    logs: {
+      table: {
+        times: "times",
+      },
+      providerChain: {
+        decisionChain: "Decision chain",
+      },
+      details: {
+        clickStatusCode: "Click status code",
+      },
+    },
+  },
+  "provider-chain": {},
+};
+
+function renderWithIntl(node: ReactNode) {
+  return renderToStaticMarkup(
+    <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
+      <div id="root">{node}</div>
+    </NextIntlClientProvider>
+  );
+}
+
+function parseHtml(html: string) {
+  const window = new Window();
+  window.document.body.innerHTML = html;
+  return window.document;
+}
+
+describe("provider-chain-popover layout", () => {
+  test("requestCount<=1 branch keeps truncation container shrinkable", () => {
+    const html = renderWithIntl(
+      <ProviderChainPopover
+        chain={[{ id: 1, name: "p1", reason: "request_success", statusCode: 200 }]}
+        finalProvider={"Very long provider name that should truncate"}
+      />
+    );
+    const document = parseHtml(html);
+
+    const container = document.querySelector("#root > div");
+    const containerClass = container?.getAttribute("class") ?? "";
+    expect(containerClass).toContain("min-w-0");
+    expect(containerClass).toContain("w-full");
+
+    const truncateNode = document.querySelector("#root span.truncate");
+    expect(truncateNode).not.toBeNull();
+  });
+
+  test("requestCount>1 branch uses w-full/min-w-0 button and flex-1 name container", () => {
+    const html = renderWithIntl(
+      <ProviderChainPopover
+        chain={[
+          { id: 1, name: "p1", reason: "retry_failed" },
+          { id: 2, name: "p2", reason: "request_success", statusCode: 200 },
+        ]}
+        finalProvider={"Very long provider name that should truncate"}
+      />
+    );
+    const document = parseHtml(html);
+
+    const button = document.querySelector("#root button");
+    expect(button).not.toBeNull();
+    const buttonClass = button?.getAttribute("class") ?? "";
+    expect(buttonClass).toContain("w-full");
+    expect(buttonClass).toContain("min-w-0");
+
+    const nameContainer = document.querySelector("#root button .flex-1.min-w-0");
+    expect(nameContainer).not.toBeNull();
+
+    const countBadge = Array.from(document.querySelectorAll('#root [data-slot="badge"]')).find(
+      (node) => (node.getAttribute("class") ?? "").includes("ml-1")
+    );
+    expect(countBadge).not.toBeUndefined();
+  });
+});

+ 4 - 4
src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx

@@ -55,7 +55,7 @@ export function ProviderChainPopover({
   // 如果只有一次请求,不显示 popover,只显示带 Tooltip 的名称
   if (requestCount <= 1) {
     return (
-      <div className={`${maxWidthClass} min-w-0`}>
+      <div className={`${maxWidthClass} min-w-0 w-full`}>
         <TooltipProvider>
           <Tooltip delayDuration={300}>
             <TooltipTrigger asChild>
@@ -78,11 +78,11 @@ export function ProviderChainPopover({
         <Button
           type="button"
           variant="ghost"
-          className="h-auto p-0 font-normal hover:bg-transparent max-w-full shrink min-w-0"
+          className="h-auto p-0 font-normal hover:bg-transparent w-full min-w-0"
           aria-label={`${displayName} - ${requestCount}${t("logs.table.times")}`}
         >
-          <span className="flex items-center gap-1 min-w-0">
-            <div className={`${maxWidthClass} min-w-0`}>
+          <span className="flex w-full items-center gap-1 min-w-0">
+            <div className={`${maxWidthClass} min-w-0 flex-1`}>
               <TooltipProvider>
                 <Tooltip delayDuration={300}>
                   <TooltipTrigger asChild>

+ 184 - 0
src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx

@@ -0,0 +1,184 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import type { ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import { act } from "react";
+import { describe, expect, test, vi } from "vitest";
+
+import type { UsageLogRow } from "@/repository/usage-logs";
+
+vi.mock("next-intl", () => ({
+  useTranslations: () => (key: string) => key,
+}));
+
+vi.mock("@/components/ui/tooltip", () => ({
+  TooltipProvider: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+}));
+
+vi.mock("@/components/ui/relative-time", () => ({
+  RelativeTime: ({ fallback }: { fallback: string }) => <span>{fallback}</span>,
+}));
+
+vi.mock("./model-display-with-redirect", () => ({
+  ModelDisplayWithRedirect: ({
+    currentModel,
+    onRedirectClick,
+  }: {
+    currentModel: string | null;
+    onRedirectClick?: () => void;
+  }) => (
+    <button type="button" data-slot="model-redirect" onClick={onRedirectClick}>
+      {currentModel ?? "-"}
+    </button>
+  ),
+}));
+
+vi.mock("./error-details-dialog", () => ({
+  ErrorDetailsDialog: () => <div data-slot="error-details-dialog" />,
+}));
+
+import { UsageLogsTable } from "./usage-logs-table";
+
+function makeLog(overrides: Partial<UsageLogRow>): UsageLogRow {
+  return {
+    id: 1,
+    createdAt: new Date(),
+    sessionId: null,
+    requestSequence: null,
+    userName: "u",
+    keyName: "k",
+    providerName: "p",
+    model: "m",
+    originalModel: null,
+    endpoint: "/v1/messages",
+    statusCode: 200,
+    inputTokens: 1,
+    outputTokens: 1,
+    cacheCreationInputTokens: 0,
+    cacheReadInputTokens: 0,
+    cacheCreation5mInputTokens: 0,
+    cacheCreation1hInputTokens: 0,
+    cacheTtlApplied: null,
+    totalTokens: 2,
+    costUsd: "0.01",
+    costMultiplier: null,
+    durationMs: 100,
+    ttfbMs: 50,
+    errorMessage: null,
+    providerChain: null,
+    blockedBy: null,
+    blockedReason: null,
+    userAgent: null,
+    messagesCount: null,
+    context1mApplied: null,
+    specialSettings: null,
+    ...overrides,
+  };
+}
+
+describe("usage-logs-table multiplier badge", () => {
+  test("does not render multiplier badge for null/undefined/empty/NaN/Infinity", () => {
+    for (const costMultiplier of [null, undefined, "", "NaN", "Infinity"] as const) {
+      const html = renderToStaticMarkup(
+        <UsageLogsTable
+          logs={[makeLog({ id: 1, costMultiplier })]}
+          total={1}
+          page={1}
+          pageSize={50}
+          onPageChange={() => {}}
+          isPending={false}
+        />
+      );
+
+      expect(html).not.toContain("×0.00");
+      expect(html).not.toContain("×NaN");
+      expect(html).not.toContain("×Infinity");
+    }
+  });
+
+  test("renders multiplier badge when finite and != 1", () => {
+    const html = renderToStaticMarkup(
+      <UsageLogsTable
+        logs={[makeLog({ id: 1, costMultiplier: "0.2" })]}
+        total={1}
+        page={1}
+        pageSize={50}
+        onPageChange={() => {}}
+        isPending={false}
+      />
+    );
+
+    expect(html).toContain("×0.20");
+    expect(html).toContain("0.20x");
+  });
+
+  test("renders warmup skipped and blocked labels", () => {
+    const htmlWarmup = renderToStaticMarkup(
+      <UsageLogsTable
+        logs={[makeLog({ id: 1, blockedBy: "warmup" })]}
+        total={1}
+        page={1}
+        pageSize={50}
+        onPageChange={() => {}}
+        isPending={false}
+      />
+    );
+    expect(htmlWarmup).toContain("logs.table.skipped");
+
+    const htmlBlocked = renderToStaticMarkup(
+      <UsageLogsTable
+        logs={[makeLog({ id: 1, blockedBy: "sensitive_word" })]}
+        total={1}
+        page={1}
+        pageSize={50}
+        onPageChange={() => {}}
+        isPending={false}
+      />
+    );
+    expect(htmlBlocked).toContain("logs.table.blocked");
+  });
+
+  test("invokes model redirect and pagination callbacks", async () => {
+    const onPageChange = vi.fn();
+    const container = document.createElement("div");
+    document.body.appendChild(container);
+
+    const root = createRoot(container);
+    await act(async () => {
+      root.render(
+        <UsageLogsTable
+          logs={[makeLog({ id: 1, costMultiplier: "0.2" })]}
+          total={100}
+          page={1}
+          pageSize={50}
+          onPageChange={onPageChange}
+          isPending={false}
+        />
+      );
+    });
+
+    // Trigger model redirect click (covers onRedirectClick handler)
+    const redirectButton = container.querySelector('button[data-slot="model-redirect"]');
+    expect(redirectButton).not.toBeNull();
+    await act(async () => {
+      redirectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+    });
+
+    // Trigger pagination (covers onClick handlers)
+    const nextButton = Array.from(container.querySelectorAll("button")).find((b) =>
+      (b.textContent ?? "").includes("logs.table.nextPage")
+    );
+    expect(nextButton).not.toBeUndefined();
+    await act(async () => {
+      nextButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+    });
+    expect(onPageChange).toHaveBeenCalledWith(2);
+
+    await act(async () => {
+      root.unmount();
+    });
+    container.remove();
+  });
+});

+ 75 - 112
src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx

@@ -93,6 +93,26 @@ export function UsageLogsTable({
                 const isWarmupSkipped = log.blockedBy === "warmup";
                 const isMutedRow = isNonBilling || isWarmupSkipped;
 
+                // 计算倍率(用于 Provider 列 Badge 和成本明细)
+                const successfulProvider =
+                  log.providerChain && log.providerChain.length > 0
+                    ? [...log.providerChain]
+                        .reverse()
+                        .find(
+                          (item) =>
+                            item.reason === "request_success" || item.reason === "retry_success"
+                        )
+                    : null;
+
+                const actualCostMultiplier =
+                  successfulProvider?.costMultiplier ?? log.costMultiplier;
+                const multiplier =
+                  actualCostMultiplier === "" || actualCostMultiplier == null
+                    ? null
+                    : Number(actualCostMultiplier);
+                const hasCostBadge =
+                  multiplier != null && Number.isFinite(multiplier) && multiplier !== 1;
+
                 return (
                   <TableRow
                     key={log.id}
@@ -125,99 +145,58 @@ export function UsageLogsTable({
                       ) : (
                         <div className="flex items-start gap-2">
                           <div className="flex flex-col items-start gap-0.5 min-w-0 flex-1">
-                            {(() => {
-                              // 计算倍率,用于判断是否显示 Badge
-                              const successfulProvider =
-                                log.providerChain && log.providerChain.length > 0
-                                  ? [...log.providerChain]
-                                      .reverse()
-                                      .find(
-                                        (item) =>
-                                          item.reason === "request_success" ||
-                                          item.reason === "retry_success"
-                                      )
-                                  : null;
-                              const actualCostMultiplier =
-                                successfulProvider?.costMultiplier ?? log.costMultiplier;
-                              const hasCostBadge =
-                                !!actualCostMultiplier &&
-                                parseFloat(String(actualCostMultiplier)) !== 1.0;
-
-                              return (
-                                <>
-                                  <div className="w-full">
-                                    <ProviderChainPopover
-                                      chain={log.providerChain ?? []}
-                                      finalProvider={
-                                        (log.providerChain && log.providerChain.length > 0
-                                          ? log.providerChain[log.providerChain.length - 1].name
-                                          : null) ||
-                                        log.providerName ||
-                                        tChain("circuit.unknown")
-                                      }
-                                      hasCostBadge={hasCostBadge}
-                                    />
-                                  </div>
-                                  {/* 摘要文字(第二行显示,左对齐) */}
-                                  {log.providerChain &&
-                                    log.providerChain.length > 0 &&
-                                    formatProviderSummary(log.providerChain, tChain) && (
-                                      <div className="w-full">
-                                        <TooltipProvider>
-                                          <Tooltip delayDuration={300}>
-                                            <TooltipTrigger asChild>
-                                              <span className="text-xs text-muted-foreground cursor-help truncate max-w-[200px] block text-left">
-                                                {formatProviderSummary(log.providerChain, tChain)}
-                                              </span>
-                                            </TooltipTrigger>
-                                            <TooltipContent
-                                              side="bottom"
-                                              align="start"
-                                              className="max-w-[500px]"
-                                            >
-                                              <p className="text-xs whitespace-normal break-words font-mono">
-                                                {formatProviderSummary(log.providerChain, tChain)}
-                                              </p>
-                                            </TooltipContent>
-                                          </Tooltip>
-                                        </TooltipProvider>
-                                      </div>
-                                    )}
-                                </>
-                              );
-                            })()}
+                            <div className="w-full">
+                              <ProviderChainPopover
+                                chain={log.providerChain ?? []}
+                                finalProvider={
+                                  (log.providerChain && log.providerChain.length > 0
+                                    ? log.providerChain[log.providerChain.length - 1].name
+                                    : null) ||
+                                  log.providerName ||
+                                  tChain("circuit.unknown")
+                                }
+                                hasCostBadge={hasCostBadge}
+                              />
+                            </div>
+                            {/* 摘要文字(第二行显示,左对齐) */}
+                            {log.providerChain &&
+                              log.providerChain.length > 0 &&
+                              formatProviderSummary(log.providerChain, tChain) && (
+                                <div className="w-full">
+                                  <TooltipProvider>
+                                    <Tooltip delayDuration={300}>
+                                      <TooltipTrigger asChild>
+                                        <span className="text-xs text-muted-foreground cursor-help truncate max-w-[200px] block text-left">
+                                          {formatProviderSummary(log.providerChain, tChain)}
+                                        </span>
+                                      </TooltipTrigger>
+                                      <TooltipContent
+                                        side="bottom"
+                                        align="start"
+                                        className="max-w-[500px]"
+                                      >
+                                        <p className="text-xs whitespace-normal break-words font-mono">
+                                          {formatProviderSummary(log.providerChain, tChain)}
+                                        </p>
+                                      </TooltipContent>
+                                    </Tooltip>
+                                  </TooltipProvider>
+                                </div>
+                              )}
                           </div>
                           {/* 显示供应商倍率 Badge(不为 1.0 时) */}
-                          {(() => {
-                            // 从决策链中找到最后一个成功的供应商,使用它的倍率
-                            const successfulProvider =
-                              log.providerChain && log.providerChain.length > 0
-                                ? [...log.providerChain]
-                                    .reverse()
-                                    .find(
-                                      (item) =>
-                                        item.reason === "request_success" ||
-                                        item.reason === "retry_success"
-                                    )
-                                : null;
-
-                            const actualCostMultiplier =
-                              successfulProvider?.costMultiplier ?? log.costMultiplier;
-
-                            return actualCostMultiplier &&
-                              parseFloat(String(actualCostMultiplier)) !== 1.0 ? (
-                              <Badge
-                                variant="outline"
-                                className={
-                                  parseFloat(String(actualCostMultiplier)) > 1.0
-                                    ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0"
-                                    : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0"
-                                }
-                              >
-                                ×{parseFloat(String(actualCostMultiplier)).toFixed(2)}
-                              </Badge>
-                            ) : null;
-                          })()}
+                          {hasCostBadge && multiplier != null ? (
+                            <Badge
+                              variant="outline"
+                              className={
+                                multiplier > 1
+                                  ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0"
+                                  : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0"
+                              }
+                            >
+                              ×{multiplier.toFixed(2)}
+                            </Badge>
+                          ) : null}
                         </div>
                       )}
                     </TableCell>
@@ -391,27 +370,11 @@ export function UsageLogsTable({
                                   {formatTokenAmount(log.cacheReadInputTokens)} tokens (0.1x)
                                 </div>
                               )}
-                              {(() => {
-                                const successfulProvider =
-                                  log.providerChain && log.providerChain.length > 0
-                                    ? [...log.providerChain]
-                                        .reverse()
-                                        .find(
-                                          (item) =>
-                                            item.reason === "request_success" ||
-                                            item.reason === "retry_success"
-                                        )
-                                    : null;
-                                const actualCostMultiplier =
-                                  successfulProvider?.costMultiplier ?? log.costMultiplier;
-                                return actualCostMultiplier &&
-                                  parseFloat(String(actualCostMultiplier)) !== 1.0 ? (
-                                  <div>
-                                    {t("logs.billingDetails.multiplier")}:{" "}
-                                    {parseFloat(String(actualCostMultiplier)).toFixed(2)}x
-                                  </div>
-                                ) : null;
-                              })()}
+                              {hasCostBadge && multiplier != null ? (
+                                <div>
+                                  {t("logs.billingDetails.multiplier")}: {multiplier.toFixed(2)}x
+                                </div>
+                              ) : null}
                             </TooltipContent>
                           </Tooltip>
                         </TooltipProvider>

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

@@ -0,0 +1,280 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import type { ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import { act } from "react";
+import { describe, expect, test, vi } from "vitest";
+
+import type { UsageLogRow } from "@/repository/usage-logs";
+
+let mockLogs: UsageLogRow[] = [];
+let mockIsLoading = false;
+let mockIsError = false;
+let mockError: unknown = null;
+let mockHasNextPage = false;
+let mockIsFetchingNextPage = false;
+
+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,
+  }),
+}));
+
+vi.mock("@/hooks/use-virtualizer", () => ({
+  useVirtualizer: () => ({
+    getTotalSize: () => mockLogs.length * 52,
+    getVirtualItems: () => [
+      ...mockLogs.map((_, index) => ({
+        index,
+        start: index * 52,
+        size: 52,
+      })),
+      ...(mockHasNextPage
+        ? [
+            {
+              index: mockLogs.length,
+              start: mockLogs.length * 52,
+              size: 52,
+            },
+          ]
+        : []),
+    ],
+  }),
+}));
+
+vi.mock("@/lib/utils/provider-chain-formatter", () => ({
+  formatProviderSummary: () => "provider summary",
+}));
+
+vi.mock("@/actions/usage-logs", () => ({
+  getUsageLogsBatch: vi.fn(),
+}));
+
+vi.mock("@/components/ui/tooltip", () => ({
+  TooltipProvider: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+}));
+
+vi.mock("@/components/ui/button", () => ({
+  Button: ({ children, className, ...props }: React.ComponentProps<"button">) => (
+    <button className={className} {...props}>
+      {children}
+    </button>
+  ),
+}));
+
+vi.mock("@/components/ui/badge", () => ({
+  Badge: ({ children, className }: React.ComponentProps<"span">) => (
+    <span className={className}>{children}</span>
+  ),
+}));
+
+vi.mock("@/components/ui/relative-time", () => ({
+  RelativeTime: ({ fallback }: { fallback: string }) => <span>{fallback}</span>,
+}));
+
+vi.mock("./model-display-with-redirect", () => ({
+  ModelDisplayWithRedirect: ({ currentModel }: { currentModel: string | null }) => (
+    <span>{currentModel ?? "-"}</span>
+  ),
+}));
+
+vi.mock("./error-details-dialog", () => ({
+  ErrorDetailsDialog: () => <div data-slot="error-details-dialog" />,
+}));
+
+import { VirtualizedLogsTable } from "./virtualized-logs-table";
+
+function makeLog(overrides: Partial<UsageLogRow>): UsageLogRow {
+  return {
+    id: 1,
+    createdAt: new Date(),
+    sessionId: null,
+    requestSequence: null,
+    userName: "u",
+    keyName: "k",
+    providerName: "p",
+    model: "m",
+    originalModel: null,
+    endpoint: "/v1/messages",
+    statusCode: 200,
+    inputTokens: 1,
+    outputTokens: 1,
+    cacheCreationInputTokens: 0,
+    cacheReadInputTokens: 0,
+    cacheCreation5mInputTokens: 0,
+    cacheCreation1hInputTokens: 0,
+    cacheTtlApplied: null,
+    totalTokens: 2,
+    costUsd: "0.01",
+    costMultiplier: null,
+    durationMs: 100,
+    ttfbMs: 50,
+    errorMessage: null,
+    providerChain: null,
+    blockedBy: null,
+    blockedReason: null,
+    userAgent: null,
+    messagesCount: null,
+    context1mApplied: null,
+    specialSettings: null,
+    ...overrides,
+  };
+}
+
+describe("virtualized-logs-table multiplier badge", () => {
+  test("renders loading/error/empty states", () => {
+    mockIsError = false;
+    mockError = null;
+    mockHasNextPage = false;
+    mockIsFetchingNextPage = false;
+
+    mockIsLoading = true;
+    mockLogs = [];
+    expect(
+      renderToStaticMarkup(<VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />)
+    ).toContain("logs.stats.loading");
+
+    mockIsLoading = false;
+    mockIsError = true;
+    mockError = new Error("boom");
+    expect(
+      renderToStaticMarkup(<VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />)
+    ).toContain("boom");
+
+    mockIsError = false;
+    mockError = null;
+    mockLogs = [];
+    expect(
+      renderToStaticMarkup(<VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />)
+    ).toContain("logs.table.noData");
+  });
+
+  test("does not render cost multiplier badge for null/undefined/empty/NaN/Infinity", () => {
+    mockIsLoading = false;
+    mockIsError = false;
+    mockError = null;
+    mockHasNextPage = false;
+    mockIsFetchingNextPage = false;
+
+    for (const costMultiplier of [null, undefined, "", "NaN", "Infinity"] as const) {
+      mockLogs = [makeLog({ id: 1, costMultiplier })];
+      const html = renderToStaticMarkup(
+        <VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />
+      );
+      expect(html).not.toContain("xNaN");
+      expect(html).not.toContain("xInfinity");
+      expect(html).not.toContain("x0.00");
+    }
+  });
+
+  test("renders cost multiplier badge when finite and != 1", () => {
+    mockIsLoading = false;
+    mockIsError = false;
+    mockError = null;
+    mockHasNextPage = false;
+    mockIsFetchingNextPage = false;
+
+    mockLogs = [makeLog({ id: 1, costMultiplier: "0.2" })];
+    const html = renderToStaticMarkup(
+      <VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />
+    );
+    expect(html).toContain("x0.20");
+  });
+
+  test("shows scroll-to-top button after scroll and triggers scrollTo", async () => {
+    mockIsLoading = false;
+    mockIsError = false;
+    mockError = null;
+    mockHasNextPage = false;
+    mockIsFetchingNextPage = false;
+    mockLogs = [makeLog({ id: 1, costMultiplier: null })];
+
+    const container = document.createElement("div");
+    document.body.appendChild(container);
+
+    const root = createRoot(container);
+    await act(async () => {
+      root.render(<VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />);
+    });
+
+    const scroller = container.querySelector(
+      "div.h-\\[600px\\].overflow-auto"
+    ) as HTMLDivElement | null;
+    expect(scroller).not.toBeNull();
+
+    if (scroller) {
+      // happy-dom may not implement scrollTo; stub for assertion
+      const scrollToMock = vi.fn();
+      (scroller as unknown as { scrollTo: typeof scrollToMock }).scrollTo = scrollToMock;
+      await act(async () => {
+        scroller.scrollTop = 600;
+        scroller.dispatchEvent(new Event("scroll"));
+      });
+
+      expect(container.innerHTML).toContain("logs.table.scrollToTop");
+
+      const button = container.querySelector("button.fixed") as HTMLButtonElement | null;
+      expect(button).not.toBeNull();
+      await act(async () => {
+        button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+      });
+      expect(scrollToMock).toHaveBeenCalled();
+    }
+
+    await act(async () => {
+      root.unmount();
+    });
+    container.remove();
+  });
+
+  test("renders blocked badge and loader row when applicable", () => {
+    mockIsLoading = false;
+    mockIsError = false;
+    mockError = null;
+    mockHasNextPage = true;
+    mockIsFetchingNextPage = false;
+
+    mockLogs = [makeLog({ id: 1, blockedBy: "sensitive_word" })];
+    const html = renderToStaticMarkup(
+      <VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />
+    );
+    expect(html).toContain("logs.table.blocked");
+
+    // Loader row should render when hasNextPage=true
+    expect(html).toContain("animate-spin");
+  });
+
+  test("renders provider summary and fetching state when enabled", () => {
+    mockIsLoading = false;
+    mockIsError = false;
+    mockError = null;
+    mockHasNextPage = true;
+    mockIsFetchingNextPage = true;
+
+    mockLogs = [
+      makeLog({
+        id: 1,
+        costMultiplier: null,
+        providerChain: [{ id: 1, name: "p1", reason: "request_success", statusCode: 200 }],
+      }),
+    ];
+
+    const html = renderToStaticMarkup(
+      <VirtualizedLogsTable filters={{}} autoRefreshEnabled={false} />
+    );
+    expect(html).toContain("provider summary");
+    expect(html).toContain("logs.table.loadingMore");
+  });
+});

+ 21 - 16
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx

@@ -295,7 +295,7 @@ export function VirtualizedLogsTable({
                       </span>
                     ) : (
                       <div className="flex flex-col items-start gap-0.5 min-w-0">
-                        <div className="flex items-center gap-1 min-w-0">
+                        <div className="flex items-center gap-1 min-w-0 w-full overflow-hidden">
                           {(() => {
                             // 计算倍率,用于判断是否显示 Badge
                             const successfulProvider =
@@ -310,34 +310,39 @@ export function VirtualizedLogsTable({
                                 : null;
                             const actualCostMultiplier =
                               successfulProvider?.costMultiplier ?? log.costMultiplier;
+                            const multiplier = Number(actualCostMultiplier);
                             const hasCostBadge =
-                              !!actualCostMultiplier &&
-                              parseFloat(String(actualCostMultiplier)) !== 1.0;
+                              actualCostMultiplier !== "" &&
+                              actualCostMultiplier != null &&
+                              Number.isFinite(multiplier) &&
+                              multiplier !== 1;
 
                             return (
                               <>
-                                <ProviderChainPopover
-                                  chain={log.providerChain ?? []}
-                                  finalProvider={
-                                    (log.providerChain && log.providerChain.length > 0
-                                      ? log.providerChain[log.providerChain.length - 1].name
-                                      : null) ||
-                                    log.providerName ||
-                                    tChain("circuit.unknown")
-                                  }
-                                  hasCostBadge={hasCostBadge}
-                                />
+                                <div className="flex-1 min-w-0 overflow-hidden">
+                                  <ProviderChainPopover
+                                    chain={log.providerChain ?? []}
+                                    finalProvider={
+                                      (log.providerChain && log.providerChain.length > 0
+                                        ? log.providerChain[log.providerChain.length - 1].name
+                                        : null) ||
+                                      log.providerName ||
+                                      tChain("circuit.unknown")
+                                    }
+                                    hasCostBadge={hasCostBadge}
+                                  />
+                                </div>
                                 {/* Cost multiplier badge */}
                                 {hasCostBadge && (
                                   <Badge
                                     variant="outline"
                                     className={
-                                      parseFloat(String(actualCostMultiplier)) > 1.0
+                                      multiplier > 1
                                         ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0"
                                         : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0"
                                     }
                                   >
-                                    x{parseFloat(String(actualCostMultiplier)).toFixed(2)}
+                                    x{multiplier.toFixed(2)}
                                   </Badge>
                                 )}
                               </>