Merge pull request #39684 from BerriAI/litellm_lit_4738_table_scrolling

fix(ui): scroll admin table rows inside the table instead of the page
This commit is contained in:
ryan-crabbe-berri 2026-09-03 17:30:53 -07:00 • committed by GitHub
commit b7d1e89667
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 287 additions and 58 deletions

View file

@ -0,0 +1,218 @@
import { test, expect, type APIRequestContext, type Locator, type Page as PlaywrightPage } from "@playwright/test";
import { ADMIN_STORAGE_PATH } from "../../constants";
import { Page } from "../../fixtures/pages";
import { navigateToPage } from "../../helpers/navigation";
import { CHAT_MODEL_A, masterKey, sendChatCompletion, waitForSpendLog } from "../../helpers/traffic";
const VIEWPORT = { width: 1280, height: 720 };
const SEED_ROWS = 40;
const LOG_ROWS = 20;
const BODY_SCROLL_PX = 500;
const MAX_FOOTER_GAP_PX = 40;
interface GeneratedKey {
key: string;
}
interface CreatedTeam {
team_id: string;
}
interface CreatedModel {
model_info: { id: string };
}
interface BoxMetrics {
top: number;
bottom: number;
scrollHeight: number;
clientHeight: number;
scrollWidth: number;
clientWidth: number;
}
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const adminHeaders = (): Record<string, string> => ({ Authorization: `Bearer ${masterKey()}` });
const appShellMain = (page: PlaywrightPage): Locator => page.locator("main").first();
const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true });
const visibleDataTable = (page: PlaywrightPage): Locator => visibleTestId(page, "data-table-root").first();
const visibleRows = (page: PlaywrightPage): Locator => visibleDataTable(page).locator("tbody tr");
const metrics = (locator: Locator): Promise<BoxMetrics> =>
locator.evaluate((el) => {
const rect = el.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
};
});
async function postOk<T>(request: APIRequestContext, path: string, data: Record<string, unknown>): Promise<T> {
const res = await request.post(path, { headers: adminHeaders(), data });
expect(res.ok(), `POST ${path} failed (${res.status()}): ${await res.text()}`).toBe(true);
return (await res.json()) as T;
}
const oneAtATime = <T>(count: number, call: (index: number) => Promise<T>): Promise<readonly T[]> =>
Array.from({ length: count }, (_, i) => i).reduce<Promise<readonly T[]>>(
async (previous, i) => [...(await previous), await call(i)],
Promise.resolve([]),
);
async function expectRowsAtLeast(page: PlaywrightPage, count: number): Promise<void> {
await expect.poll(() => visibleRows(page).count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(count);
}
async function setRowsPerPage(page: PlaywrightPage, size: "25" | "50" | "100"): Promise<void> {
await visibleTestId(page, "pagination-page-size").click();
await page.getByRole("option", { name: size, exact: true }).click();
}
async function expectBodyIsTheOnlyScroller(page: PlaywrightPage): Promise<void> {
const scroller = await metrics(appShellMain(page));
const body = visibleTestId(page, "data-table-scroller");
const bodyBefore = await metrics(body);
const headBefore = await metrics(visibleTestId(page, "data-table-head"));
const footer = await metrics(visibleDataTable(page));
expect(scroller.scrollHeight, "page scroller must not overflow vertically").toBe(scroller.clientHeight);
expect(scroller.scrollWidth, "page scroller must not overflow horizontally").toBe(scroller.clientWidth);
expect(bodyBefore.scrollHeight, "table body must be the element that scrolls").toBeGreaterThan(
bodyBefore.clientHeight,
);
expect(footer.bottom, "pagination footer must be inside the page").toBeLessThanOrEqual(scroller.bottom);
expect(scroller.bottom - footer.bottom, "pagination footer must sit at the bottom of the page").toBeLessThanOrEqual(
MAX_FOOTER_GAP_PX,
);
await body.evaluate((el, px) => {
el.scrollTop = px;
}, BODY_SCROLL_PX);
await expect.poll(() => body.evaluate((el) => el.scrollTop)).toBeGreaterThan(0);
const headAfter = await metrics(visibleTestId(page, "data-table-head"));
expect(Math.round(headAfter.top), "header must stay put while the body scrolls").toBe(Math.round(headBefore.top));
}
const rowsPaintingPastAnAncestor = (page: PlaywrightPage): Promise<string[]> =>
visibleDataTable(page)
.locator("table")
.evaluate((table) => {
const scrollsVertically = (el: Element): boolean =>
/auto|scroll/.test(getComputedStyle(el).overflowY) && el.scrollHeight > el.clientHeight + 1;
const boxesUpToTheScroller = (el: Element | null): Element[] =>
el === null || el === document.body || scrollsVertically(el)
? []
: [el, ...boxesUpToTheScroller(el.parentElement)];
const describe = (el: Element): string =>
`<${el.tagName.toLowerCase()} class="${el.getAttribute("class") ?? ""}">`;
return Array.from(table.querySelectorAll("tbody tr")).flatMap((row, index) => {
const rowBottom = row.getBoundingClientRect().bottom;
return boxesUpToTheScroller(row.parentElement)
.filter((box) => rowBottom > box.getBoundingClientRect().bottom + 1)
.map(
(box) =>
`row ${index} bottom ${Math.round(rowBottom)} past ${describe(box)} bottom ${Math.round(box.getBoundingClientRect().bottom)}`,
);
});
});
test.describe("Admin tables scroll inside the page", () => {
test.use({ storageState: ADMIN_STORAGE_PATH, viewport: VIEWPORT });
test("Virtual Keys: rows scroll under a sticky header and the page itself never scrolls", async ({
page,
request,
}) => {
const suffix = uniqueSuffix();
const keys = await oneAtATime(SEED_ROWS, (i) =>
postOk<GeneratedKey>(request, "/key/generate", { key_alias: `e2e-scroll-key-${suffix}-${i}` }),
);
try {
await navigateToPage(page, Page.ApiKeys);
await expectRowsAtLeast(page, SEED_ROWS);
await expectBodyIsTheOnlyScroller(page);
} finally {
await request.post("/key/delete", { headers: adminHeaders(), data: { keys: keys.map((k) => k.key) } });
}
});
test("Teams: rows scroll under a sticky header and the page itself never scrolls", async ({ page, request }) => {
const suffix = uniqueSuffix();
const teams = await oneAtATime(SEED_ROWS, (i) =>
postOk<CreatedTeam>(request, "/team/new", { team_alias: `e2e-scroll-team-${suffix}-${i}` }),
);
try {
await navigateToPage(page, Page.Teams);
await expectRowsAtLeast(page, SEED_ROWS);
await expectBodyIsTheOnlyScroller(page);
} finally {
await request.post("/team/delete", { headers: adminHeaders(), data: { team_ids: teams.map((t) => t.team_id) } });
}
});
test("Request Logs: rows scroll under a sticky header and the page itself never scrolls", async ({
page,
request,
}) => {
const suffix = uniqueSuffix();
const ids = await oneAtATime(LOG_ROWS, (i) =>
sendChatCompletion(request, { model: CHAT_MODEL_A, prompt: `scroll ${suffix} ${i}` }),
);
await waitForSpendLog(request, ids[ids.length - 1]);
await navigateToPage(page, Page.Logs);
await expect(visibleTestId(page, "datatable-search")).toBeVisible({ timeout: 20_000 });
await setRowsPerPage(page, "25");
await expectRowsAtLeast(page, LOG_ROWS);
await expectBodyIsTheOnlyScroller(page);
});
test("Tags: no row paints past the box it lives in", async ({ page, request }) => {
const suffix = uniqueSuffix();
const names = Array.from({ length: SEED_ROWS }, (_, i) => `e2e-scroll-tag-${suffix}-${i}`);
await oneAtATime(SEED_ROWS, (i) =>
postOk<unknown>(request, "/tag/new", { name: names[i], description: "LIT-4738 scroll" }),
);
try {
await navigateToPage(page, Page.TagManagement);
await expectRowsAtLeast(page, SEED_ROWS);
expect(await rowsPaintingPastAnAncestor(page)).toEqual([]);
} finally {
await oneAtATime(SEED_ROWS, (i) =>
request.post("/tag/delete", { headers: adminHeaders(), data: { name: names[i] } }),
);
}
});
test("Model Hub: no row paints past the box it lives in", async ({ page, request }) => {
const suffix = uniqueSuffix();
const models = await oneAtATime(SEED_ROWS, (i) =>
postOk<CreatedModel>(request, "/model/new", {
model_name: `e2e-scroll-model-${suffix}-${i}`,
litellm_params: {
model: "openai/fake-gpt-4",
api_base: `http://127.0.0.1:${process.env.MOCK_LLM_PORT ?? "8090"}/v1`,
api_key: "fake-key",
},
}),
);
try {
await navigateToPage(page, Page.ModelHubTable);
await expectRowsAtLeast(page, SEED_ROWS);
expect(await rowsPaintingPastAnAncestor(page)).toEqual([]);
} finally {
await oneAtATime(SEED_ROWS, (i) =>
request.post("/model/delete", { headers: adminHeaders(), data: { id: models[i].model_info.id } }),
);
}
});
});

View file

@ -41,6 +41,7 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
data={data}
columns={columns}
getRowId={(tag, index) => tag.name || String(index)}
fillHeight
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}

View file

@ -126,7 +126,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
}, [accessToken]);
return (
<div className="mx-4 h-[75vh]">
<div className="mx-4 h-full">
{selectedTagId ? (
<TagInfoView
tagId={selectedTagId}
@ -139,7 +139,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
editTag={editTag}
/>
) : (
<div className="mt-2 h-[75vh] w-full gap-2 p-8">
<div className="flex h-full w-full flex-col p-8 pt-10">
<div className="mt-2 mb-4 flex w-full items-center justify-between">
<h1>Tag Management</h1>
<div className="flex items-center space-x-2">
@ -162,23 +162,21 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
</p>
</div>
<Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
<Button className="mb-4 self-start" onClick={() => setIsCreateModalVisible(true)}>
+ Create New Tag
</Button>
<div className="mt-2 grid h-[75vh] w-full grid-cols-1 gap-2 pt-2 pb-2">
<div>
<TagTable
data={tags}
isLoading={isLoadingTags}
onEdit={(tag) => {
setSelectedTagId(tag.name);
setEditTag(true);
}}
onDelete={handleDelete}
onSelectTag={setSelectedTagId}
/>
</div>
<div className="mt-2 flex min-h-0 flex-1 flex-col">
<TagTable
data={tags}
isLoading={isLoadingTags}
onEdit={(tag) => {
setSelectedTagId(tag.name);
setEditTag(true);
}}
onDelete={handleDelete}
onSelectTag={setSelectedTagId}
/>
</div>
{/* Create Tag Modal */}

View file

@ -137,8 +137,8 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
/>
</div>
) : (
<div className="mx-4 h-[75vh]">
<div className="gap-2 p-8 h-[75vh] w-full mt-2">
<div className="mx-4">
<div className="gap-2 p-8 w-full mt-2">
<div className="flex justify-between mt-2 w-full items-center mb-4">
<h1 className="text-xl font-semibold tracking-tight text-foreground">Vector Store Management</h1>
<div className="flex items-center space-x-2">

View file

@ -400,7 +400,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
}
return (
<div className="mx-4 h-[75vh]">
<div className="mx-4">
{publicPage == false ? (
<div className="w-full m-2 mt-2 p-8">
{/* Header with Title, Description and URL */}

View file

@ -559,6 +559,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
{
key: "your-teams",
label: "Your Teams",
className: "flex min-h-0 flex-1 flex-col",
children: (
<>
<TeamsTable
@ -608,6 +609,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
{
key: "available-teams",
label: "Available Teams",
className: "min-h-0 flex-1 overflow-y-auto",
children: <AvailableTeamsPanel accessToken={accessToken} userID={userID} />,
},
...(isProxyAdminRole(userRole || "")
@ -615,6 +617,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
{
key: "default-settings",
label: "Default Team Settings",
className: "min-h-0 flex-1 overflow-y-auto",
children: <TeamSSOSettings accessToken={accessToken} userID={userID || ""} userRole={userRole || ""} />,
},
]
@ -622,7 +625,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
];
return (
<main className={selectedTeamId ? "px-12 py-6" : "p-8"}>
<main className={selectedTeamId ? "px-12 py-6" : "flex h-full flex-col p-8"}>
{selectedTeamId ? (
<TeamInfoView
teamId={selectedTeamId}
@ -642,7 +645,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
premiumUser={premiumUser}
/>
) : (
<Tabs defaultValue={tabItems[0].key} className="gap-6">
<Tabs defaultValue={tabItems[0].key} className="min-h-0 flex-1 gap-6">
<PageHeader
icon={<Users />}
title="Teams"
@ -674,7 +677,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
)}
/>
{tabItems.map((item) => (
<TabsContent key={item.key} value={item.key}>
<TabsContent key={item.key} value={item.key} className={item.className}>
{item.children}
</TabsContent>
))}

View file

@ -164,7 +164,7 @@ export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDelet
isLoading={isLoading}
loadingMessage="Loading teams..."
noDataMessage="No teams found"
maxBodyHeight="calc(75vh - 210px)"
fillHeight
size="compact"
toolbar={(table) => (
<>

View file

@ -256,7 +256,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
}
return (
<div className="flex h-full flex-col gap-6 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col gap-6">
<PageHeader
icon={<KeyRound />}
title="Virtual Keys"
@ -283,7 +283,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
isLoading={isLoading}
loadingMessage="Loading keys..."
noDataMessage="No keys found"
maxBodyHeight="calc(75vh - 210px)"
fillHeight
size="compact"
toolbar={(table) => (
<>

View file

@ -613,8 +613,11 @@ describe("DataTable layout", () => {
it("makes the header sticky and constrains body height when maxBodyHeight is set", () => {
render(<DataTable data={CHARLIE_ALICE_BOB} columns={nameEmailColumns} maxBodyHeight={240} />);
expect(screen.getByTestId("data-table-head")).toHaveClass("sticky");
expect(screen.getByTestId("data-table-scroller")).toHaveStyle({ maxHeight: "240px" });
const scroller = screen.getByTestId("data-table-scroller");
expect(scroller).toHaveStyle({ maxHeight: "240px" });
expect(scroller).toHaveClass("overflow-auto");
expect(scroller).toHaveClass("[&_[data-slot=table-container]]:overflow-visible");
expect(screen.getByTestId("data-table-head")).toHaveClass("sticky", "bg-background");
});
it("caps fillHeight at the parent's height instead of stretching to it, so a short table stays short", () => {

View file

@ -59,18 +59,22 @@ const noop = () => {};
/**
* Height-filling mode. The table still sizes to its rows; the parent's height is only a ceiling, so
* a short table keeps its footer under the last row and a long one scrolls its rows instead of the
* page. `table-container` is the Table primitive's own overflow-x wrapper; left as a scroll box it
* captures the sticky header and the header scrolls away with the rows. And rows pass under that
* header, which the semi-transparent header row tint alone would not hide.
* page.
*/
const FILL_CLASSES = {
outer: "flex max-h-full min-h-0 flex-col",
frame: "flex min-h-0 flex-col",
body: "min-h-0 [&_[data-slot=table-container]]:overflow-visible",
body: "min-h-0",
} as const;
const NO_FILL_CLASSES = { outer: "", frame: "", body: "" } as const;
const STICKY_CLASSES = {
body: "[&_[data-slot=table-container]]:overflow-visible",
header: "bg-background",
} as const;
const NO_FILL_CLASSES = { outer: "", frame: "", body: "", header: "" } as const;
const NO_STICKY_CLASSES = { body: "", header: "" } as const;
function columnDefId<TData, TValue>(column: ColumnDef<TData, TValue>): string | undefined {
if ("id" in column && typeof column.id === "string") {
@ -533,6 +537,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
const visibleColumnCount = table.getVisibleLeafColumns().length;
const stickyHeader = maxBodyHeight !== undefined || fillHeight;
const fill = fillHeight ? FILL_CLASSES : NO_FILL_CLASSES;
const sticky = stickyHeader ? STICKY_CLASSES : NO_STICKY_CLASSES;
const tableStyle = enableColumnResizing ? { width: table.getTotalSize(), minWidth: "100%" } : undefined;
const renderPagination = (): React.ReactNode => {
@ -593,13 +598,13 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
{toolbar !== undefined && <div className="shrink-0 border-b border-border px-4 py-3">{toolbar(table)}</div>}
<div
data-testid="data-table-scroller"
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", fill.body)}
className={cn(stickyHeader ? "overflow-auto" : "overflow-x-auto", sticky.body, fill.body)}
style={maxBodyHeight !== undefined ? { maxHeight: maxBodyHeight } : undefined}
>
<TableRoot className={enableColumnResizing ? "table-fixed" : ""} style={tableStyle}>
<TableHeader
data-testid="data-table-head"
className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", fill.header)}
className={cn(stickyHeader ? "sticky top-0 z-sticky" : "", sticky.header)}
>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50">

View file

@ -443,7 +443,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
}, []);
return (
<div className="w-full h-full overflow-hidden">
<div className="w-full">
{selectedKey ? (
<KeyInfoView
keyId={selectedKey.token}
@ -453,7 +453,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
onDelete={refetch}
/>
) : (
<div className="py-4 flex-1 overflow-hidden">
<div className="py-4">
<DataTable
data={displayKeys}
columns={columns}
@ -471,7 +471,6 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi
columnResizeMode="onChange"
isLoading={isLoading || isFetching}
loadingMessage="Loading keys..."
maxBodyHeight="75vh"
size="compact"
toolbar={(table) => (
<>

View file

@ -216,24 +216,22 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
const canCreateKey = userRole !== "Admin Viewer" && userRole !== "proxy_admin_viewer";
return (
<main className="h-[75vh] p-8">
<div className="flex h-full flex-col">
<VirtualKeysTable
headerActions={
canCreateKey ? (
<CreateKey
key={selectedTeam ? selectedTeam.team_id : null}
team={selectedTeam as Team | null}
teams={teams as Team[]}
data={keys}
addKey={addKey}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : undefined
}
/>
</div>
<main className="flex h-full flex-col p-8">
<VirtualKeysTable
headerActions={
canCreateKey ? (
<CreateKey
key={selectedTeam ? selectedTeam.team_id : null}
team={selectedTeam as Team | null}
teams={teams as Team[]}
data={keys}
addKey={addKey}
autoOpenCreate={autoOpenCreate}
prefillData={prefillData}
/>
) : undefined
}
/>
</main>
);
};

View file

@ -86,6 +86,7 @@ export function RequestLogsTable({
data={data}
columns={columns}
getRowId={(row) => row.request_id}
fillHeight
sortingMode="server"
sorting={sorting}
onSortingChange={onSortingChange}

View file

@ -27,6 +27,9 @@ const AUDIT_LOGS_TAB: LogsTab = { id: "audit logs", label: "Audit Logs" };
const DELETED_KEYS_TAB: LogsTab = { id: "deleted keys", label: "Deleted Keys" };
const DELETED_TEAMS_TAB: LogsTab = { id: "deleted teams", label: "Deleted Teams" };
const tabContentClassName = (tabId: LogsTabId): string =>
tabId === REQUEST_LOGS_TAB.id ? "flex min-h-0 flex-1 flex-col" : "min-h-0 flex-1 overflow-y-auto";
export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) {
const [activeTab, setActiveTab] = useState<LogsTabId>(REQUEST_LOGS_TAB.id);
const canViewAuditLogs = useCan("viewAuditLogs");
@ -78,8 +81,8 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
};
return (
<div className="box-border w-full overflow-x-hidden p-6">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as LogsTabId)}>
<div className="flex h-full w-full flex-col p-6">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as LogsTabId)} className="min-h-0 flex-1">
<TabsList variant="line">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="flex-none">
@ -88,7 +91,7 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} keepMounted>
<TabsContent key={tab.id} value={tab.id} keepMounted className={tabContentClassName(tab.id)}>
{renderPanel(tab.id)}
</TabsContent>
))}