mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
commit
b7d1e89667
14 changed files with 287 additions and 58 deletions
218
tests/e2e/ui/tests/tables/tableScrolling.spec.ts
Normal file
218
tests/e2e/ui/tests/tables/tableScrolling.spec.ts
Normal 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 } }),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export function RequestLogsTable({
|
|||
data={data}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.request_id}
|
||||
fillHeight
|
||||
sortingMode="server"
|
||||
sorting={sorting}
|
||||
onSortingChange={onSortingChange}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue