diff --git a/tests/e2e/ui/tests/tables/tableScrolling.spec.ts b/tests/e2e/ui/tests/tables/tableScrolling.spec.ts new file mode 100644 index 00000000000..f51c772185c --- /dev/null +++ b/tests/e2e/ui/tests/tables/tableScrolling.spec.ts @@ -0,0 +1,239 @@ +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"; + +/** + * LIT-4738 table scrolling. On the paginated pages the app shell
is the only page scroller + * and must never overflow: rows scroll inside the table body under a header that stays put, and the + * pagination footer sits at the bottom of the page instead of below the fold or inside a clipped + * box. Pages that keep plain page scrolling must never paint rows past a fixed-height ancestor. + * The viewport is pinned so "more rows than fit" means the same thing on every machine. + */ + +const VIEWPORT = { width: 1280, height: 720 }; +const SEED_ROWS = 40; +const LOG_ROWS = 20; +const BODY_SCROLL_PX = 500; +/** p-8 on Keys and Teams, p-6 on Logs: the footer may sit at most one page padding above the edge. */ +const MAX_FOOTER_GAP_PX = 40; + +const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +const adminHeaders = (): Record => ({ + Authorization: `Bearer ${masterKey()}`, +}); + +/** Keys and Teams render a
of their own inside the app shell's, which comes first in document order. */ +const pageScroller = (page: PlaywrightPage): Locator => page.locator("main").first(); + +/** Tabs keep every panel mounted, so a bare test id can match a hidden table; scope to the visible one. */ +const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true }); + +/** The page's data table; Model Hub also renders a plain links table above it, which this skips. */ +const visibleDataTable = (page: PlaywrightPage): Locator => visibleTestId(page, "data-table-root").first(); + +const visibleRows = (page: PlaywrightPage): Locator => visibleDataTable(page).locator("tbody tr"); + +interface BoxMetrics { + top: number; + bottom: number; + scrollHeight: number; + clientHeight: number; + scrollWidth: number; + clientWidth: number; +} + +const metrics = (locator: Locator): Promise => + 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( + request: APIRequestContext, + path: string, + data: Record, +): Promise> { + 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 Record; +} + +/** One request at a time: a burst of forty management calls starves the proxy's transaction pool. */ +async function oneAtATime(count: number, call: (index: number) => Promise): Promise { + const results: T[] = []; + for (let i = 0; i < count; i++) { + results.push(await call(i)); + } + return results; +} + +async function expectRowsAtLeast(page: PlaywrightPage, count: number): Promise { + await expect.poll(() => visibleRows(page).count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(count); +} + +async function setRowsPerPage(page: PlaywrightPage, size: "25" | "50" | "100"): Promise { + await visibleTestId(page, "pagination-page-size").click(); + await page.getByRole("option", { name: size, exact: true }).click(); +} + +/** + * The page scroller stays put, the table body is what scrolls, the header does not move while the + * body scrolls, and the pagination footer sits at the bottom of the page. + */ +async function expectBodyIsTheOnlyScroller(page: PlaywrightPage): Promise { + const scroller = await metrics(pageScroller(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)); +} + +/** + * Every row must sit inside each ancestor up to the nearest one that really scrolls vertically; a + * fixed-height box that neither grows nor scrolls lets rows paint past its bottom edge. + */ +const rowsPaintingPastAnAncestor = (page: PlaywrightPage): Promise => + visibleDataTable(page) + .locator("table") + .evaluate((table) => { + const scrollsVertically = (el: Element): boolean => + /auto|scroll/.test(getComputedStyle(el).overflowY) && el.scrollHeight > el.clientHeight + 1; + 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; + const spills: string[] = []; + for (let el = row.parentElement; el && el !== document.body && !scrollsVertically(el); el = el.parentElement) { + const bottom = el.getBoundingClientRect().bottom; + if (rowBottom > bottom + 1) { + spills.push( + `row ${index} bottom ${Math.round(rowBottom)} past ${describe(el)} bottom ${Math.round(bottom)}`, + ); + } + } + return spills; + }); + }); + +type Cleanup = (request: APIRequestContext) => Promise; +const cleanups: Cleanup[] = []; + +test.describe("Admin tables scroll inside the page", () => { + test.use({ storageState: ADMIN_STORAGE_PATH, viewport: VIEWPORT }); + + test.afterEach(async ({ request }) => { + for (const cleanup of cleanups.splice(0)) { + // Teardown must never turn a passing test red or mask a real failure. + await cleanup(request).catch(() => {}); + } + }); + + test("Virtual Keys: rows scroll under a sticky header and the page itself never scrolls", async ({ + page, + request, + }) => { + const suffix = uniqueSuffix(); + const created = await oneAtATime(SEED_ROWS, (i) => + postOk(request, "/key/generate", { key_alias: `e2e-scroll-key-${suffix}-${i}` }), + ); + cleanups.push((r) => r.post("/key/delete", { headers: adminHeaders(), data: { keys: created.map((k) => k.key) } })); + + await navigateToPage(page, Page.ApiKeys); + await expectRowsAtLeast(page, SEED_ROWS); + await expectBodyIsTheOnlyScroller(page); + }); + + test("Teams: rows scroll under a sticky header and the page itself never scrolls", async ({ page, request }) => { + const suffix = uniqueSuffix(); + const created = await oneAtATime(SEED_ROWS, (i) => + postOk(request, "/team/new", { team_alias: `e2e-scroll-team-${suffix}-${i}` }), + ); + cleanups.push((r) => + r.post("/team/delete", { headers: adminHeaders(), data: { team_ids: created.map((t) => t.team_id) } }), + ); + + await navigateToPage(page, Page.Teams); + await expectRowsAtLeast(page, SEED_ROWS); + await expectBodyIsTheOnlyScroller(page); + }); + + 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(request, "/tag/new", { name: names[i], description: "LIT-4738 scroll" })); + cleanups.push((r) => + oneAtATime(SEED_ROWS, (i) => r.post("/tag/delete", { headers: adminHeaders(), data: { name: names[i] } })), + ); + + await navigateToPage(page, Page.TagManagement); + await expectRowsAtLeast(page, SEED_ROWS); + expect(await rowsPaintingPastAnAncestor(page)).toEqual([]); + }); + + test("Model Hub: no row paints past the box it lives in", async ({ page, request }) => { + const suffix = uniqueSuffix(); + const created = await oneAtATime(SEED_ROWS, (i) => + postOk(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", + }, + }), + ); + cleanups.push((r) => + oneAtATime(SEED_ROWS, (i) => + r.post("/model/delete", { headers: adminHeaders(), data: { id: created[i].model_info.id } }), + ), + ); + + await navigateToPage(page, Page.ModelHubTable); + await expectRowsAtLeast(page, SEED_ROWS); + expect(await rowsPaintingPastAnAncestor(page)).toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx index 488190a0fdf..feea01f19ca 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx @@ -41,6 +41,7 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag data={data} columns={columns} getRowId={(tag, index) => tag.name || String(index)} + fillHeight sortingMode="client" sorting={sorting} onSortingChange={setSorting} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx index a04afcd6d45..583492bf837 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/index.tsx @@ -126,7 +126,7 @@ const TagManagement: React.FC = ({ accessToken, userID, userRole }) => }, [accessToken]); return ( -
+
{selectedTagId ? ( = ({ accessToken, userID, userRole }) => editTag={editTag} /> ) : ( -
+

Tag Management

@@ -162,23 +162,21 @@ const TagManagement: React.FC = ({ accessToken, userID, userRole }) =>

- -
-
- { - setSelectedTagId(tag.name); - setEditTag(true); - }} - onDelete={handleDelete} - onSelectTag={setSelectedTagId} - /> -
+
+ { + setSelectedTagId(tag.name); + setEditTag(true); + }} + onDelete={handleDelete} + onSelectTag={setSelectedTagId} + />
{/* Create Tag Modal */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx index f74f444ce26..1745c710c51 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx @@ -137,8 +137,8 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID />
) : ( -
-
+
+

Vector Store Management

diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 475e3dcd70b..74f44db5cb9 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -400,7 +400,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, } return ( -
+
{publicPage == false ? (
{/* Header with Title, Description and URL */} diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index ef58237a6aa..4be91f22339 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -559,6 +559,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser { key: "your-teams", label: "Your Teams", + className: "flex min-h-0 flex-1 flex-col", children: ( <> = ({ accessToken, userID, userRole, premiumUser { key: "available-teams", label: "Available Teams", + className: "min-h-0 flex-1 overflow-y-auto", children: , }, ...(isProxyAdminRole(userRole || "") @@ -615,6 +617,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser { key: "default-settings", label: "Default Team Settings", + className: "min-h-0 flex-1 overflow-y-auto", children: , }, ] @@ -622,7 +625,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser ]; return ( -
+
{selectedTeamId ? ( = ({ accessToken, userID, userRole, premiumUser premiumUser={premiumUser} /> ) : ( - + } title="Teams" @@ -674,7 +677,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser )} /> {tabItems.map((item) => ( - + {item.children} ))} diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx index 0b38d51e36a..3fb19e522f3 100644 --- a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx @@ -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) => ( <> diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index ebedf57af45..f28ae27b6bc 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -256,7 +256,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { } return ( -
+
} 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) => ( <> diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx index f502ea77127..daf20d927e7 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.test.tsx @@ -613,8 +613,12 @@ describe("DataTable layout", () => { it("makes the header sticky and constrains body height when maxBodyHeight is set", () => { render(); - 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"); + // As in fill mode: the Table primitive's own overflow container would otherwise capture the sticky header. + 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", () => { diff --git a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx index 60267606951..f04430c1698 100644 --- a/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx +++ b/ui/litellm-dashboard/src/components/shared/DataTable/DataTable.tsx @@ -59,18 +59,28 @@ 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; + +/** + * Sticky header, in both fill and maxBodyHeight mode. `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. + */ +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(column: ColumnDef): string | undefined { if ("id" in column && typeof column.id === "string") { @@ -533,6 +543,7 @@ export function DataTable(props: DataTableProps { @@ -593,13 +604,13 @@ export function DataTable(props: DataTableProps{toolbar(table)}
}
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index aa9df4a0319..b0380255b95 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -443,7 +443,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi }, []); return ( -
+
{selectedKey ? ( ) : ( -
+
( <> diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 614b3b7af62..dcadc141103 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -216,24 +216,22 @@ const UserDashboard: React.FC = ({ const canCreateKey = userRole !== "Admin Viewer" && userRole !== "proxy_admin_viewer"; return ( -
-
- - ) : undefined - } - /> -
+
+ + ) : undefined + } + />
); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx index 17caa4466fa..db108ee681c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx @@ -86,6 +86,7 @@ export function RequestLogsTable({ data={data} columns={columns} getRowId={(row) => row.request_id} + fillHeight sortingMode="server" sorting={sorting} onSortingChange={onSortingChange} diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index f20285dffa0..aadf90fad6c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -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(REQUEST_LOGS_TAB.id); const canViewAuditLogs = useCan("viewAuditLogs"); @@ -78,8 +81,8 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p }; return ( -
- setActiveTab(value as LogsTabId)}> +
+ setActiveTab(value as LogsTabId)} className="min-h-0 flex-1"> {tabs.map((tab) => ( @@ -88,7 +91,7 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p ))} {tabs.map((tab) => ( - + {renderPanel(tab.id)} ))}