From 93a62891072b468933b71438ddb8389d379001a8 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 22 Jun 2026 20:48:04 +0800 Subject: [PATCH 1/3] fix(usage-logs): exclude blank model names from filter options Blank or whitespace-only model values appeared in the model filter dropdown. Add SQL-level btrim exclusion in getUsedModels, filter blank entries in the lazy-loading hook, and guard the UI select rendering. Includes unit tests for repository and UI layers. Fixes #1285 --- .../_components/filters/request-filters.tsx | 3 +- .../logs/_hooks/use-lazy-filter-options.ts | 13 +++- src/repository/usage-logs.ts | 14 ++++- ...ard-logs-sessionid-suggestions-ui.test.tsx | 62 +++++++++++++++++-- .../usage-logs-model-filter-options.test.ts | 33 ++++++++++ 5 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 tests/unit/repository/usage-logs-model-filter-options.test.ts diff --git a/src/app/[locale]/dashboard/logs/_components/filters/request-filters.tsx b/src/app/[locale]/dashboard/logs/_components/filters/request-filters.tsx index 6af778544..2fe6b60e7 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/request-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/filters/request-filters.tsx @@ -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]); useEffect(() => { isMountedRef.current = true; @@ -256,7 +257,7 @@ export function RequestFilters({ {t("logs.filters.allModels")} - {models.map((model) => ( + {modelOptions.map((model) => ( {model} diff --git a/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts b/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts index b9e2cce80..3cb1c5603 100644 --- a/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts +++ b/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts @@ -98,8 +98,13 @@ function createLazyFilterHook( * 惰性加载 Models 列表 * 用于 Model 筛选器下拉,展开时才加载数据 */ -export const useLazyModels: () => UseLazyFilterOptionsReturn = - createLazyFilterHook(getModelList); +export const useLazyModels: () => UseLazyFilterOptionsReturn = createLazyFilterHook( + async () => { + const result = await getModelList(); + if (!result.ok) return result; + return { ...result, data: result.data.filter(isNonBlankString) }; + } +); /** * 惰性加载 StatusCodes 列表 @@ -114,3 +119,7 @@ export const useLazyStatusCodes: () => UseLazyFilterOptionsReturn = */ export const useLazyEndpoints: () => UseLazyFilterOptionsReturn = createLazyFilterHook(getEndpointList); + +function isNonBlankString(value: string): boolean { + return value.trim().length > 0; +} diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index fa789060c..4974dd714 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -1469,10 +1469,20 @@ export async function getUsedModels(): Promise { 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}) <> ''` + ) + ) .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; } /** diff --git a/tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx b/tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx index 0aa44f355..e4d15835a 100644 --- a/tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx +++ b/tests/unit/dashboard-logs-sessionid-suggestions-ui.test.tsx @@ -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", () => ({})); @@ -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, @@ -132,7 +138,11 @@ vi.mock("@/components/ui/select", () => ({ Select: ({ children }: { children?: ReactNode }) =>
{children}
, SelectTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, SelectContent: ({ children }: { children?: ReactNode }) =>
{children}
, - SelectItem: ({ children }: { children?: ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children?: ReactNode; value: string }) => ( +
+ {children} +
+ ), SelectValue: () => , })); @@ -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, 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(), }), @@ -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( + {}} + 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(); diff --git a/tests/unit/repository/usage-logs-model-filter-options.test.ts b/tests/unit/repository/usage-logs-model-filter-options.test.ts new file mode 100644 index 000000000..c52a52b2e --- /dev/null +++ b/tests/unit/repository/usage-logs-model-filter-options.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test, vi } from "vitest"; + +function createThenableQuery(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", () => ({ + db: { selectDistinct: selectDistinctMock }, + })); + + const { getUsedModels } = await import("@/repository/usage-logs"); + + await expect(getUsedModels()).resolves.toEqual(["claude-sonnet-4-5", "gpt-4o"]); + }); +}); From 931690e2bdca8374ca9cb184e35d07b7c796d529 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 22 Jun 2026 20:48:04 +0800 Subject: [PATCH 2/3] chore: bump biome schema reference to 2.4.16 --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index fec605641..64236f172 100644 --- a/biome.json +++ b/biome.json @@ -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", From 543a5b25108d667be525609979fe70116aa597e7 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 22 Jun 2026 21:19:47 +0800 Subject: [PATCH 3/3] fix(logs): drop non-string entries from lazy filter options isNonBlankString assumed its argument was always a string, so malformed API responses containing null, numbers, or other non-string values could slip through the blank check and pollute filter dropdowns or cause runtime errors. Widen the parameter to unknown and convert the helper into a type guard that rejects non-string values before trimming. Add a test verifying useLazyModels drops malformed entries and retains only valid non-blank strings. Fixes #1285 --- .../logs/_hooks/use-lazy-filter-options.ts | 4 +- ...ashboard-logs-lazy-filter-options.test.tsx | 93 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 tests/unit/dashboard-logs-lazy-filter-options.test.tsx diff --git a/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts b/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts index 3cb1c5603..814591f5f 100644 --- a/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts +++ b/src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts @@ -120,6 +120,6 @@ export const useLazyStatusCodes: () => UseLazyFilterOptionsReturn = export const useLazyEndpoints: () => UseLazyFilterOptionsReturn = createLazyFilterHook(getEndpointList); -function isNonBlankString(value: string): boolean { - return value.trim().length > 0; +function isNonBlankString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; } diff --git a/tests/unit/dashboard-logs-lazy-filter-options.test.tsx b/tests/unit/dashboard-logs-lazy-filter-options.test.tsx new file mode 100644 index 000000000..4f65ebfd7 --- /dev/null +++ b/tests/unit/dashboard-logs-lazy-filter-options.test.tsx @@ -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; + +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 { + 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( + { + 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(); + }); +});