Skip to content
Open
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
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function RequestFilters({
() => new Map(providers.map((provider) => [provider.id, provider.name])),
[providers]
);
const modelOptions = useMemo(() => models.filter((model) => model.trim().length > 0), [models]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The models array returned by the useLazyModels() hook is already filtered to exclude empty and blank strings (both at the hook level and the repository/API level). Therefore, defining modelOptions with another filter inside useMemo is redundant. We can remove this useMemo entirely and use models directly.


useEffect(() => {
isMountedRef.current = true;
Expand Down Expand Up @@ -256,7 +257,7 @@ export function RequestFilters({
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("logs.filters.allModels")}</SelectItem>
{models.map((model) => (
{modelOptions.map((model) => (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Since modelOptions is redundant and can be removed, we should map over models directly here.

Suggested change
{modelOptions.map((model) => (
{models.map((model) => (

<SelectItem key={model} value={model}>
{model}
</SelectItem>
Expand Down
13 changes: 11 additions & 2 deletions src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,13 @@ function createLazyFilterHook<T>(
* 惰性加载 Models 列表
* 用于 Model 筛选器下拉,展开时才加载数据
*/
export const useLazyModels: () => UseLazyFilterOptionsReturn<string> =
createLazyFilterHook<string>(getModelList);
export const useLazyModels: () => UseLazyFilterOptionsReturn<string> = createLazyFilterHook<string>(
async () => {
const result = await getModelList();
if (!result.ok) return result;
return { ...result, data: result.data.filter(isNonBlankString) };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

/**
* 惰性加载 StatusCodes 列表
Expand All @@ -114,3 +119,7 @@ export const useLazyStatusCodes: () => UseLazyFilterOptionsReturn<number> =
*/
export const useLazyEndpoints: () => UseLazyFilterOptionsReturn<string> =
createLazyFilterHook<string>(getEndpointList);

function isNonBlankString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
14 changes: 12 additions & 2 deletions src/repository/usage-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1469,10 +1469,20 @@ export async function getUsedModels(): Promise<string[]> {
const results = await db
.selectDistinct({ model: messageRequest.model })
.from(messageRequest)
.where(and(isNull(messageRequest.deletedAt), sql`${messageRequest.model} IS NOT NULL`))
.where(
and(
isNull(messageRequest.deletedAt),
sql`${messageRequest.model} IS NOT NULL`,
sql`btrim(${messageRequest.model}) <> ''`
)
Comment on lines +1473 to +1477

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The IS NOT NULL condition is now redundant. In PostgreSQL, btrim(NULL) returns NULL, and NULL <> '' evaluates to NULL (not TRUE), so null rows are already excluded by the btrim condition. Keeping it is harmless, but removing it simplifies the predicate.

Suggested change
and(
isNull(messageRequest.deletedAt),
sql`${messageRequest.model} IS NOT NULL`,
sql`btrim(${messageRequest.model}) <> ''`
)
and(
isNull(messageRequest.deletedAt),
sql`btrim(${messageRequest.model}) <> ''`
)
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/repository/usage-logs.ts
Line: 1473-1477

Comment:
The `IS NOT NULL` condition is now redundant. In PostgreSQL, `btrim(NULL)` returns `NULL`, and `NULL <> ''` evaluates to `NULL` (not `TRUE`), so null rows are already excluded by the `btrim` condition. Keeping it is harmless, but removing it simplifies the predicate.

```suggestion
      and(
        isNull(messageRequest.deletedAt),
        sql`btrim(${messageRequest.model}) <> ''`
      )
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

)
.orderBy(messageRequest.model);

return results.map((r) => r.model).filter((m): m is string => m !== null);
return results.map((r) => r.model).filter(isNonBlankString);
}

function isNonBlankString(value: string | null): value is string {
return typeof value === "string" && value.trim().length > 0;
}

/**
Expand Down
93 changes: 93 additions & 0 deletions tests/unit/dashboard-logs-lazy-filter-options.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* @vitest-environment happy-dom
*/

import type { ReactNode } from "react";
import { act, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { useLazyModels } from "@/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options";

const getModelListMock = vi.hoisted(() => vi.fn());

vi.mock("@/lib/api-client/v1/actions/usage-logs", () => ({
getEndpointList: vi.fn(async () => ({ ok: true, data: [] })),
getModelList: getModelListMock,
getStatusCodeList: vi.fn(async () => ({ ok: true, data: [] })),
}));

type HookSnapshot = ReturnType<typeof useLazyModels>;

function HookProbe({ onSnapshot }: { onSnapshot: (snapshot: HookSnapshot) => void }) {
const snapshot = useLazyModels();

useEffect(() => {
onSnapshot(snapshot);
}, [snapshot, onSnapshot]);

return null;
}

function renderHookProbe(node: ReactNode) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);

act(() => {
root.render(node);
});

return () => {
act(() => root.unmount());
container.remove();
};
}

async function waitForLoaded(read: () => HookSnapshot | null): Promise<HookSnapshot> {
const startedAt = Date.now();

while (Date.now() - startedAt < 1000) {
const snapshot = read();
if (snapshot?.isLoaded) return snapshot;

await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}

throw new Error("Timed out waiting for useLazyModels to load.");
}

describe("useLazyModels", () => {
beforeEach(() => {
vi.restoreAllMocks();
getModelListMock.mockReset();
});

test("drops malformed model options instead of failing the lazy load", async () => {
getModelListMock.mockResolvedValue({
ok: true,
data: ["", null, 42, " ", "claude-sonnet-4-5"] as unknown as string[],
});

let latest: HookSnapshot | null = null;
const unmount = renderHookProbe(
<HookProbe
onSnapshot={(snapshot) => {
latest = snapshot;
}}
/>
);

await act(async () => {
latest?.onOpenChange(true);
});

const loaded = await waitForLoaded(() => latest);

expect(loaded.error).toBeNull();
expect(loaded.data).toEqual(["claude-sonnet-4-5"]);

unmount();
});
});
62 changes: 57 additions & 5 deletions tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import type { ReactNode } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";

vi.mock("server-only", () => ({}));

Expand Down Expand Up @@ -39,6 +39,12 @@ const usersActionMocks = vi.hoisted(() => ({
})),
}));

const lazyFilterOptionMocks = vi.hoisted(() => ({
models: [] as string[],
endpoints: [] as string[],
statusCodes: [] as number[],
}));

vi.mock("@/actions/usage-logs", () => ({
exportUsageLogs: usageLogsActionMocks.exportUsageLogs,
getUsageLogSessionIdSuggestions: usageLogsActionMocks.getUsageLogSessionIdSuggestions,
Expand Down Expand Up @@ -132,7 +138,11 @@ vi.mock("@/components/ui/select", () => ({
Select: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
SelectContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children, value }: { children?: ReactNode; value: string }) => (
<div data-select-item="" data-select-value={value}>
{children}
</div>
),
SelectValue: () => <span />,
}));

Expand Down Expand Up @@ -160,17 +170,17 @@ vi.mock("@/components/ui/command", () => ({
// Mock lazy filter hooks
vi.mock("@/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options", () => ({
useLazyModels: () => ({
data: [],
data: lazyFilterOptionMocks.models,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Since the filtering responsibility has been moved to the useLazyModels hook, the mock for useLazyModels in this test should simulate this behavior by filtering the mocked models. This allows us to remove the redundant filtering from the RequestFilters component while keeping the integration test green and accurate.

Suggested change
data: lazyFilterOptionMocks.models,
data: lazyFilterOptionMocks.models.filter((m) => m.trim().length > 0),

isLoading: false,
onOpenChange: vi.fn(),
}),
useLazyEndpoints: () => ({
data: [],
data: lazyFilterOptionMocks.endpoints,
isLoading: false,
onOpenChange: vi.fn(),
}),
useLazyStatusCodes: () => ({
data: [],
data: lazyFilterOptionMocks.statusCodes,
isLoading: false,
onOpenChange: vi.fn(),
}),
Expand All @@ -197,7 +207,49 @@ function setReactInputValue(input: HTMLInputElement, value: string) {
input.dispatchEvent(new Event("change", { bubbles: true }));
}

beforeEach(() => {
lazyFilterOptionMocks.models = [];
lazyFilterOptionMocks.endpoints = [];
lazyFilterOptionMocks.statusCodes = [];
});

describe("UsageLogsFilters sessionId suggestions", () => {
test("should ignore empty model options before rendering Select items", async () => {
vi.clearAllMocks();
document.body.innerHTML = "";
lazyFilterOptionMocks.models = ["", " ", "claude-sonnet-4-5", "gpt-4o"];

const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);

await act(async () => {
root.render(
<UsageLogsFilters
isAdmin={false}
providers={[]}
initialKeys={[]}
filters={{}}
onChange={() => {}}
onReset={() => {}}
/>
);
});

const values = Array.from(container.querySelectorAll("[data-select-value]")).map((el) =>
el.getAttribute("data-select-value")
);
expect(values).toContain("claude-sonnet-4-5");
expect(values).toContain("gpt-4o");
expect(values).not.toContain("");
expect(values).not.toContain(" ");

await act(async () => {
root.unmount();
});
container.remove();
});

test("should debounce and require min length (>=2)", async () => {
vi.useFakeTimers();
vi.clearAllMocks();
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/repository/usage-logs-model-filter-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test, vi } from "vitest";

function createThenableQuery<T>(result: T) {
const query: any = Promise.resolve(result);
query.from = vi.fn(() => query);
query.where = vi.fn(() => query);
query.orderBy = vi.fn(() => query);
return query;
}

describe("usage log model filter options", () => {
test("getUsedModels omits empty or blank model names", async () => {
vi.resetModules();

const selectDistinctMock = vi.fn(() =>
createThenableQuery([
{ model: "" },
{ model: " " },
{ model: "claude-sonnet-4-5" },
{ model: "gpt-4o" },
{ model: null },
])
);

vi.doMock("@/drizzle/db", () => ({
Comment on lines +14 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 SQL WHERE conditions are not asserted in the test

The mock's where function ignores its arguments and always returns the same pre-populated result set (including blank/null rows). This means the test only exercises the JavaScript post-processing filter in getUsedModels, not the btrim / IS NOT NULL SQL conditions. If those SQL conditions were accidentally removed, this test would still pass because isNonBlankString on line 1481 catches the blanks at the JS level. Consider adding an assertion that where received the expected SQL conditions, or document that SQL coverage is intentionally deferred to integration tests.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/repository/usage-logs-model-filter-options.test.ts
Line: 14-25

Comment:
**SQL WHERE conditions are not asserted in the test**

The mock's `where` function ignores its arguments and always returns the same pre-populated result set (including blank/null rows). This means the test only exercises the JavaScript post-processing filter in `getUsedModels`, not the `btrim` / `IS NOT NULL` SQL conditions. If those SQL conditions were accidentally removed, this test would still pass because `isNonBlankString` on line 1481 catches the blanks at the JS level. Consider adding an assertion that `where` received the expected SQL conditions, or document that SQL coverage is intentionally deferred to integration tests.

How can I resolve this? If you propose a fix, please make it concise.

db: { selectDistinct: selectDistinctMock },
}));

const { getUsedModels } = await import("@/repository/usage-logs");

await expect(getUsedModels()).resolves.toEqual(["claude-sonnet-4-5", "gpt-4o"]);
});
});
Loading