Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
})()}
Comment on lines +539 to +552
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

您好,这部分对 costMultiplier 的校验逻辑非常棒,很严谨。

为了与本次 PR 中其他文件(如 usage-logs-table.tsx)的风格保持一致,并提高代码可读性,建议将这部分逻辑从 IIFE (立即调用函数表达式) 中提取出来。

例如,可以在组件函数体顶部(返回 JSX 之前)定义 multipliershowMultiplier 变量,然后在 JSX 中直接使用它们进行条件渲染。这样可以让 JSX 结构更简洁清晰,也更便于维护。

Comment on lines +539 to +552
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

空白字符串会被当作 0 渲染为 0.00x,建议一并视为“无倍率”

当前仅排除了 "" | null | undefined,但像 " "Number(" ") === 0isFinite,最终显示 0.00x,通常更像“无效输入”。建议先 trim() 再判断空字符串。

建议修改(仅限本段)
-                        {(() => {
-                          if (costMultiplier === "" || costMultiplier == null) return null;
-                          const multiplier = Number(costMultiplier);
+                        {(() => {
+                          if (costMultiplier == null) return null;
+                          const raw = costMultiplier.trim();
+                          if (raw === "") return null;
+                          const multiplier = Number(raw);
                           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>
                           );
                         })()}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{(() => {
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>
);
})()}
{(() => {
if (costMultiplier == null) return null;
const raw = costMultiplier.trim();
if (raw === "") return null;
const multiplier = Number(raw);
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>
);
})()}
🤖 Prompt for AI Agents
In @src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx around
lines 539 - 552, The rendering shows "0.00x" for inputs like "   " because
costMultiplier is not trimmed before numeric conversion; update the IIFE around
costMultiplier to first compute const trimmed = costMultiplier?.trim(); treat
trimmed === "" or trimmed == null as no multiplier, then parse const multiplier
= Number(trimmed) and keep the existing checks (Number.isFinite(multiplier) &&
multiplier !== 1) before rendering; reference costMultiplier and multiplier in
the change.

</div>
<div className="mt-3 pt-3 border-t flex justify-between items-center">
<span className="font-medium">{t("logs.billingDetails.totalCost")}:</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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>
Expand Down
184 changes: 184 additions & 0 deletions src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading