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
8 changes: 5 additions & 3 deletions e2e/request-logs-column-reorder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down
26 changes: 20 additions & 6 deletions features/request-log-viewer/requestLogsShared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,22 +96,27 @@ const resolveLatencyToneClasses = (latencyText: string): string => {

function RequestLogMetricChip({
ariaLabel,
label,
value,
className,
}: {
ariaLabel: string;
label?: string;
value: string;
className: string;
}) {
return (
<span
className={[
"inline-flex items-center rounded-full border px-1.5 py-0.5 font-mono text-[11px] font-semibold tabular-nums whitespace-nowrap",
"inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0.5 text-[11px] whitespace-nowrap",
className,
].join(" ")}
aria-label={ariaLabel}
>
{value}
{label ? (
<span className="font-sans text-[10px] font-medium leading-none opacity-80">{label}</span>
) : null}
<span className="font-mono font-semibold tabular-nums">{value}</span>
</span>
);
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -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"
>
<div className="inline-flex max-w-full items-center justify-center gap-1.5 whitespace-nowrap">
<div className="flex max-w-full flex-wrap items-center justify-center gap-1.5">
{hasLatency ? (
<RequestLogMetricChip
ariaLabel={`${t("request_logs.col_duration")}: ${row.latencyText}`}
label={t("request_logs.col_duration")}
value={row.latencyText}
className={resolveLatencyToneClasses(row.latencyText)}
/>
) : null}
{hasFirstToken ? (
<RequestLogMetricChip
ariaLabel={`${t("request_logs.col_first_token")}: ${row.firstTokenText}`}
label={t("request_logs.col_first_token")}
value={row.firstTokenText}
className="border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200"
/>
) : null}
<RequestLogModeChip
streaming={row.streaming}
label={
Expand Down
24 changes: 21 additions & 3 deletions packages/ui/src/data-table/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface DataTableColumn<T> {
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) */
Expand Down Expand Up @@ -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<T>(column: DataTableColumn<T>) {
return column.minWidthPx ?? parseArbitraryMinWidthPx(column.width) ?? DEFAULT_MIN_COLUMN_WIDTH;
}

function clampColumnWidth<T>(column: DataTableColumn<T>, 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)));
}
Expand Down Expand Up @@ -1207,7 +1220,7 @@ export function DataTable<T>({
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));

Expand Down Expand Up @@ -2199,7 +2212,12 @@ export function DataTable<T>({
>
<div
data-vt-cell-content-clip
className="min-w-0 max-w-full overflow-hidden"
className={[
"min-w-0 max-w-full overflow-hidden",
col.cellContentClassName,
]
.filter(Boolean)
.join(" ")}
>
<TableCellOverflowTooltip
tooltipContent={overflowTooltip}
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/data-table/DataTable.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface DataTableColumn<T> {
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) */
Expand Down
28 changes: 28 additions & 0 deletions packages/ui/src/data-table/__tests__/tableStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 12 additions & 1 deletion packages/ui/src/data-table/tableStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(column: DataTableColumn<T>) {
return column.minWidthPx ?? parseArbitraryMinWidthPx(column.width) ?? DEFAULT_MIN_COLUMN_WIDTH;
}

export function clampColumnWidth<T>(column: DataTableColumn<T>, 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)));
}
Expand Down
52 changes: 31 additions & 21 deletions pages/api-keys/components/ApiKeyColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ type CreateApiKeyColumnsOptions = {
onDelete: (index: number) => void;
};

function ApiKeyPermissionAllValue({ label }: { label: string }) {
return (
<OverflowTooltip content={label} className="block min-w-0 max-w-full">
<span className="inline-flex max-w-full min-w-0 items-center gap-1 text-green-600 dark:text-green-400">
<ShieldCheck size={14} className="shrink-0" />
<span className="min-w-0 truncate">{label}</span>
</span>
</OverflowTooltip>
);
}

export const createApiKeyColumns = ({
t,
onToggleDisable,
Expand Down Expand Up @@ -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) => (
<code className="rounded-md bg-slate-100 px-2 py-0.5 font-mono text-xs text-slate-700 dark:bg-neutral-800 dark:text-white/70">
{maskApiKey(row.key)}
</code>
),
cellClassName: "min-w-0 whitespace-nowrap",
render: (row) => {
const maskedKey = maskApiKey(row.key);
return (
<OverflowTooltip content={maskedKey} className="block min-w-0 max-w-full">
<code className="inline-block max-w-full truncate align-middle rounded-md bg-slate-100 px-2 py-0.5 font-mono text-xs text-slate-700 dark:bg-neutral-800 dark:text-white/70">
{maskedKey}
</code>
</OverflowTooltip>
);
},
},
{
key: "dailyLimit",
Expand Down Expand Up @@ -218,9 +234,9 @@ export const createApiKeyColumns = ({
))}
</div>
}
className="block min-w-0"
className="!flex min-w-0 max-w-full overflow-hidden"
>
<span className="inline-flex min-w-0 w-full items-center gap-1.5 text-xs">
<span className="flex min-w-0 max-w-full items-center gap-1.5 overflow-hidden text-xs">
<span className="inline-flex h-5 min-w-[20px] flex-shrink-0 items-center justify-center rounded-md bg-indigo-50 px-1.5 font-semibold tabular-nums text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-300">
{row["allowed-models"].length}
</span>
Expand All @@ -230,9 +246,7 @@ export const createApiKeyColumns = ({
</span>
</HoverTooltip>
) : (
<span className="inline-flex items-center gap-1 whitespace-nowrap text-green-600 dark:text-green-400">
<ShieldCheck size={14} /> {t("api_keys_page.all_models")}
</span>
<ApiKeyPermissionAllValue label={t("api_keys_page.all_models")} />
),
},
{
Expand All @@ -255,9 +269,9 @@ export const createApiKeyColumns = ({
))}
</div>
}
className="block min-w-0"
className="!flex min-w-0 max-w-full overflow-hidden"
>
<span className="inline-flex min-w-0 w-full items-center gap-1.5 text-xs">
<span className="flex min-w-0 max-w-full items-center gap-1.5 overflow-hidden text-xs">
<span className="inline-flex h-5 min-w-[20px] flex-shrink-0 items-center justify-center rounded-md bg-violet-50 px-1.5 font-semibold tabular-nums text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
{row["allowed-channel-groups"].length}
</span>
Expand All @@ -267,9 +281,7 @@ export const createApiKeyColumns = ({
</span>
</HoverTooltip>
) : (
<span className="inline-flex items-center gap-1 whitespace-nowrap text-green-600 dark:text-green-400">
<ShieldCheck size={14} /> {t("api_keys_page.all_channel_groups")}
</span>
<ApiKeyPermissionAllValue label={t("api_keys_page.all_channel_groups")} />
),
},
{
Expand All @@ -292,9 +304,9 @@ export const createApiKeyColumns = ({
))}
</div>
}
className="block min-w-0"
className="!flex min-w-0 max-w-full overflow-hidden"
>
<span className="inline-flex min-w-0 w-full items-center gap-1.5 text-xs">
<span className="flex min-w-0 max-w-full items-center gap-1.5 overflow-hidden text-xs">
<span className="inline-flex h-5 min-w-[20px] flex-shrink-0 items-center justify-center rounded-md bg-cyan-50 px-1.5 font-semibold tabular-nums text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300">
{row["allowed-channels"].length}
</span>
Expand All @@ -304,9 +316,7 @@ export const createApiKeyColumns = ({
</span>
</HoverTooltip>
) : (
<span className="inline-flex items-center gap-1 whitespace-nowrap text-green-600 dark:text-green-400">
<ShieldCheck size={14} /> {t("api_keys_page.all_channels")}
</span>
<ApiKeyPermissionAllValue label={t("api_keys_page.all_channels")} />
),
},
{
Expand Down
61 changes: 61 additions & 0 deletions pages/api-keys/components/__tests__/ApiKeyColumns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<div>
<div data-testid="key-cell">{keyColumn?.render(row, 0)}</div>
<div data-testid="models-cell">{modelsColumn?.render(row, 0)}</div>
</div>,
);

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(<div>{modelsColumn?.render(row, 0)}</div>);
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",
Expand Down
2 changes: 1 addition & 1 deletion pages/monitor/__tests__/requestLogsShared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Loading
Loading