From 80d69260b1b990fc265930f7a0aa41ed7c6e1a23 Mon Sep 17 00:00:00 2001 From: kittors Date: Tue, 23 Jun 2026 22:37:08 +0800 Subject: [PATCH] fix: improve request log metrics table overflow --- e2e/request-logs-column-reorder.spec.ts | 8 ++- .../request-log-viewer/requestLogsShared.tsx | 26 ++++++-- packages/ui/src/data-table/DataTable.tsx | 24 +++++++- packages/ui/src/data-table/DataTable.types.ts | 2 + .../data-table/__tests__/tableStorage.test.ts | 28 +++++++++ packages/ui/src/data-table/tableStorage.ts | 13 +++- pages/api-keys/components/ApiKeyColumns.tsx | 52 +++++++++------- .../__tests__/ApiKeyColumns.test.tsx | 61 +++++++++++++++++++ .../__tests__/requestLogsShared.test.ts | 2 +- .../__tests__/RequestLogsPage.test.tsx | 7 ++- 10 files changed, 185 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/data-table/__tests__/tableStorage.test.ts diff --git a/e2e/request-logs-column-reorder.spec.ts b/e2e/request-logs-column-reorder.spec.ts index 61e1941b..d6f596fa 100644 --- a/e2e/request-logs-column-reorder.spec.ts +++ b/e2e/request-logs-column-reorder.spec.ts @@ -247,16 +247,18 @@ test("Request Logs: response metrics column resize clamps at its minimum width", await page.waitForTimeout(80); const during = await readResponseMetricsColumnState(page); - expect(during.width).toBeGreaterThanOrEqual(183); - expect(during.width).toBeLessThanOrEqual(185); + expect(during.width).toBeGreaterThanOrEqual(239); + expect(during.width).toBeLessThanOrEqual(241); expect(during.text).toMatch(/Streaming|流式/); + expect(during.text).toMatch(/First Token|首 Token/); + expect(during.text).toContain("90ms"); expect(during.text).not.toContain("--"); expect(during.chipsStayInsideCell).toBe(true); await page.mouse.up(); const after = await readResponseMetricsColumnState(page); - expect(after.storedLatencyWidth).toBe(184); + expect(after.storedLatencyWidth).toBe(240); }); test("Request Logs: column reorder follows the pointer and auto-scrolls horizontally", async ({ diff --git a/features/request-log-viewer/requestLogsShared.tsx b/features/request-log-viewer/requestLogsShared.tsx index d1970c77..490de9e3 100644 --- a/features/request-log-viewer/requestLogsShared.tsx +++ b/features/request-log-viewer/requestLogsShared.tsx @@ -96,22 +96,27 @@ const resolveLatencyToneClasses = (latencyText: string): string => { function RequestLogMetricChip({ ariaLabel, + label, value, className, }: { ariaLabel: string; + label?: string; value: string; className: string; }) { return ( - {value} + {label ? ( + {label} + ) : null} + {value} ); } @@ -396,8 +401,8 @@ export function buildRequestLogsColumns( { key: "latency", label: t("request_logs.col_response_metrics"), - width: "w-52", - minWidthPx: 184, + width: "w-64", + minWidthPx: 240, headerClassName: "text-center", cellClassName: "text-center text-xs tabular-nums text-slate-700 dark:text-slate-200", render: (row) => { @@ -417,16 +422,25 @@ export function buildRequestLogsColumns( content={tooltipLines.join("\n")} disabled={tooltipLines.length === 0} placement="bottom" - className="max-w-full justify-center" + className="block max-w-full" > -
+
{hasLatency ? ( ) : null} + {hasFirstToken ? ( + + ) : null} { headerClassName?: string; /** Extra cell class */ cellClassName?: string; + /** Extra class for the inner cell content wrapper. */ + cellContentClassName?: string; /** Overflow tooltip text for a truncated cell. Primitive render output is used by default. */ overflowTooltip?: boolean | ((row: T, index: number) => string | null | undefined); /** Custom header render function (overrides label) */ @@ -246,8 +248,19 @@ function calculateScrollbarThumbs(scrollMetrics: ScrollMetrics, headerHeight: nu return { vThumb: v, hThumb: h }; } +function parseArbitraryMinWidthPx(widthClassName?: string) { + const match = widthClassName?.match(/(?:^|\s)min-w-\[(\d+(?:\.\d+)?)px\](?:\s|$)/); + if (!match) return null; + const value = Number(match[1]); + return Number.isFinite(value) && value > 0 ? Math.round(value) : null; +} + +function resolveColumnMinWidth(column: DataTableColumn) { + return column.minWidthPx ?? parseArbitraryMinWidthPx(column.width) ?? DEFAULT_MIN_COLUMN_WIDTH; +} + function clampColumnWidth(column: DataTableColumn, width: number) { - const minWidth = column.minWidthPx ?? DEFAULT_MIN_COLUMN_WIDTH; + const minWidth = resolveColumnMinWidth(column); const maxWidth = column.maxWidthPx ?? DEFAULT_MAX_COLUMN_WIDTH; return Math.max(minWidth, Math.min(maxWidth, Math.round(width))); } @@ -1207,7 +1220,7 @@ export function DataTable({ const rect = headerCell.getBoundingClientRect(); const containerRect = containerRef.current?.getBoundingClientRect(); const startWidth = rect.width; - const minWidth = column.minWidthPx ?? DEFAULT_MIN_COLUMN_WIDTH; + const minWidth = resolveColumnMinWidth(column); const maxWidth = column.maxWidthPx ?? DEFAULT_MAX_COLUMN_WIDTH; const nextStartWidth = Math.max(minWidth, Math.min(maxWidth, startWidth)); @@ -2199,7 +2212,12 @@ export function DataTable({ >
{ headerClassName?: string; /** Extra cell class */ cellClassName?: string; + /** Extra class for the inner cell content wrapper. */ + cellContentClassName?: string; /** Overflow tooltip text for a truncated cell. Primitive render output is used by default. */ overflowTooltip?: boolean | ((row: T, index: number) => string | null | undefined); /** Custom header render function (overrides label) */ diff --git a/packages/ui/src/data-table/__tests__/tableStorage.test.ts b/packages/ui/src/data-table/__tests__/tableStorage.test.ts new file mode 100644 index 00000000..a8567010 --- /dev/null +++ b/packages/ui/src/data-table/__tests__/tableStorage.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; +import type { DataTableColumn } from "../DataTable.types"; +import { clampColumnWidth } from "../tableStorage"; + +describe("tableStorage", () => { + test("clamps stored widths to arbitrary min-width classes", () => { + const column: DataTableColumn<{ key: string }> = { + key: "key", + label: "Key", + width: "w-[320px] min-w-[320px]", + render: () => null, + }; + + expect(clampColumnWidth(column, 88)).toBe(320); + }); + + test("lets explicit minWidthPx override width class inference", () => { + const column: DataTableColumn<{ key: string }> = { + key: "key", + label: "Key", + width: "w-[320px] min-w-[320px]", + minWidthPx: 180, + render: () => null, + }; + + expect(clampColumnWidth(column, 88)).toBe(180); + }); +}); diff --git a/packages/ui/src/data-table/tableStorage.ts b/packages/ui/src/data-table/tableStorage.ts index db99aaf3..efa11b0b 100644 --- a/packages/ui/src/data-table/tableStorage.ts +++ b/packages/ui/src/data-table/tableStorage.ts @@ -128,8 +128,19 @@ export function calculateScrollbarThumbs(scrollMetrics: ScrollMetrics, headerHei return { vThumb: v, hThumb: h }; } +function parseArbitraryMinWidthPx(widthClassName?: string) { + const match = widthClassName?.match(/(?:^|\s)min-w-\[(\d+(?:\.\d+)?)px\](?:\s|$)/); + if (!match) return null; + const value = Number(match[1]); + return Number.isFinite(value) && value > 0 ? Math.round(value) : null; +} + +function resolveColumnMinWidth(column: DataTableColumn) { + return column.minWidthPx ?? parseArbitraryMinWidthPx(column.width) ?? DEFAULT_MIN_COLUMN_WIDTH; +} + export function clampColumnWidth(column: DataTableColumn, width: number) { - const minWidth = column.minWidthPx ?? DEFAULT_MIN_COLUMN_WIDTH; + const minWidth = resolveColumnMinWidth(column); const maxWidth = column.maxWidthPx ?? DEFAULT_MAX_COLUMN_WIDTH; return Math.max(minWidth, Math.min(maxWidth, Math.round(width))); } diff --git a/pages/api-keys/components/ApiKeyColumns.tsx b/pages/api-keys/components/ApiKeyColumns.tsx index f83fc10a..aae87d63 100644 --- a/pages/api-keys/components/ApiKeyColumns.tsx +++ b/pages/api-keys/components/ApiKeyColumns.tsx @@ -31,6 +31,17 @@ type CreateApiKeyColumnsOptions = { onDelete: (index: number) => void; }; +function ApiKeyPermissionAllValue({ label }: { label: string }) { + return ( + + + + {label} + + + ); +} + export const createApiKeyColumns = ({ t, onToggleDisable, @@ -84,12 +95,17 @@ export const createApiKeyColumns = ({ key: "key", label: t("api_keys_page.col_key"), width: "w-[320px] min-w-[320px]", - cellClassName: "whitespace-nowrap", - render: (row) => ( - - {maskApiKey(row.key)} - - ), + cellClassName: "min-w-0 whitespace-nowrap", + render: (row) => { + const maskedKey = maskApiKey(row.key); + return ( + + + {maskedKey} + + + ); + }, }, { key: "dailyLimit", @@ -218,9 +234,9 @@ export const createApiKeyColumns = ({ ))}
} - className="block min-w-0" + className="!flex min-w-0 max-w-full overflow-hidden" > - + {row["allowed-models"].length} @@ -230,9 +246,7 @@ export const createApiKeyColumns = ({ ) : ( - - {t("api_keys_page.all_models")} - + ), }, { @@ -255,9 +269,9 @@ export const createApiKeyColumns = ({ ))}
} - className="block min-w-0" + className="!flex min-w-0 max-w-full overflow-hidden" > - + {row["allowed-channel-groups"].length} @@ -267,9 +281,7 @@ export const createApiKeyColumns = ({ ) : ( - - {t("api_keys_page.all_channel_groups")} - + ), }, { @@ -292,9 +304,9 @@ export const createApiKeyColumns = ({ ))}
} - className="block min-w-0" + className="!flex min-w-0 max-w-full overflow-hidden" > - + {row["allowed-channels"].length} @@ -304,9 +316,7 @@ export const createApiKeyColumns = ({ ) : ( - - {t("api_keys_page.all_channels")} - + ), }, { diff --git a/pages/api-keys/components/__tests__/ApiKeyColumns.test.tsx b/pages/api-keys/components/__tests__/ApiKeyColumns.test.tsx index 682db020..69a6a6e0 100644 --- a/pages/api-keys/components/__tests__/ApiKeyColumns.test.tsx +++ b/pages/api-keys/components/__tests__/ApiKeyColumns.test.tsx @@ -101,6 +101,67 @@ describe("ApiKeyColumns", () => { expect(keyColumn?.width).toBe("w-[320px] min-w-[320px]"); }); + test("renders key and unrestricted permission cells with bounded truncation", () => { + const row: ApiKeyEntry = { + key: "sk-team-a-abcdefghijklmnopqrstuvwxyz1234567890", + name: "Test key", + "created-at": "2026-04-28T00:00:00Z", + }; + const columns = createApiKeyColumns({ + t, + onCopy: vi.fn(), + onDelete: vi.fn(), + onEdit: vi.fn(), + onImportToCcSwitch: vi.fn(), + onToggleDisable: vi.fn(), + onViewUsage: vi.fn(), + }); + const keyColumn = columns.find((column) => column.key === "key"); + const modelsColumn = columns.find((column) => column.key === "allowedModels"); + + const { container } = render( +
+
{keyColumn?.render(row, 0)}
+
{modelsColumn?.render(row, 0)}
+
, + ); + + const code = container.querySelector("code"); + expect(code).toHaveClass("max-w-full"); + expect(code).toHaveClass("truncate"); + expect(screen.getByText("api_keys_page.all_models")).toHaveClass("truncate"); + }); + + test("keeps restricted permission summaries bounded inside the cell", () => { + const row: ApiKeyEntry = { + key: "sk-team-a-abcdefghijklmnopqrstuvwxyz1234567890", + name: "Test key", + "created-at": "2026-04-28T00:00:00Z", + "allowed-models": ["deepseek-r1-ultra-long-name", "gpt-5.3-codex"], + }; + const columns = createApiKeyColumns({ + t, + onCopy: vi.fn(), + onDelete: vi.fn(), + onEdit: vi.fn(), + onImportToCcSwitch: vi.fn(), + onToggleDisable: vi.fn(), + onViewUsage: vi.fn(), + }); + const modelsColumn = columns.find((column) => column.key === "allowedModels"); + + const { container } = render(
{modelsColumn?.render(row, 0)}
); + const trigger = container.querySelector("[data-tooltip-managed='true']"); + const summary = container.querySelector("span.flex.max-w-full.overflow-hidden"); + + expect(trigger).toHaveClass("!flex"); + expect(trigger).toHaveClass("max-w-full"); + expect(trigger).toHaveClass("overflow-hidden"); + expect(summary).toHaveClass("flex"); + expect(summary).toHaveClass("min-w-0"); + expect(screen.getByText("deepseek-r1-ultra-long-name")).toHaveClass("truncate"); + }); + test("shows API key spending limits as a dedicated cost column", async () => { const row: ApiKeyEntry = { key: "sk-test", diff --git a/pages/monitor/__tests__/requestLogsShared.test.ts b/pages/monitor/__tests__/requestLogsShared.test.ts index 4540d290..a50b4397 100644 --- a/pages/monitor/__tests__/requestLogsShared.test.ts +++ b/pages/monitor/__tests__/requestLogsShared.test.ts @@ -65,7 +65,7 @@ describe("requestLogsShared", () => { expect(columns.find((column) => column.key === "latency")?.label).toBe( "request_logs.col_response_metrics", ); - expect(columns.find((column) => column.key === "latency")?.minWidthPx).toBe(184); + expect(columns.find((column) => column.key === "latency")?.minWidthPx).toBe(240); expect(keys.indexOf("latency")).toBeLessThan(keys.indexOf("apiKeyName")); expect(keys.indexOf("inputTokens")).toBeLessThan(keys.indexOf("apiKeyName")); expect(keys.indexOf("cachedTokens")).toBeLessThan(keys.indexOf("model")); diff --git a/pages/request-logs/__tests__/RequestLogsPage.test.tsx b/pages/request-logs/__tests__/RequestLogsPage.test.tsx index 3ac8daa3..e645a503 100644 --- a/pages/request-logs/__tests__/RequestLogsPage.test.tsx +++ b/pages/request-logs/__tests__/RequestLogsPage.test.tsx @@ -153,7 +153,7 @@ describe("RequestLogsPage", () => { mocks.clearUsageLogs.mockReset(); }); - test("renders first token latency in the response metrics tooltip", async () => { + test("renders first token latency in the response metrics cell and tooltip", async () => { await i18n.changeLanguage("en"); const user = userEvent.setup(); @@ -209,9 +209,10 @@ describe("RequestLogsPage", () => { expect(within(table).getByRole("columnheader", { name: "Response Metrics" })).toBeInTheDocument(); expect(within(table).getByText("Streaming")).toBeInTheDocument(); expect(within(table).getByText("1.20s")).toBeInTheDocument(); - expect(within(table).queryByText("183ms")).not.toBeInTheDocument(); + expect(within(table).getByText("First Token")).toBeInTheDocument(); + expect(within(table).getByText("183ms")).toBeInTheDocument(); - await user.hover(within(table).getByLabelText("Duration: 1.20s")); + await user.hover(within(table).getByLabelText("First Token: 183ms")); expect(await screen.findByRole("tooltip")).toHaveTextContent("First Token: 183ms"); });