mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-18 04:44:37 +00:00
perf(ui): page the member tables and look up budget rows once per render (#26667)
* perf(ui): look up a team member's budget row once per render TeamMemberTab resolved each row's membership with Array.find() over team_memberships inside every extra column's render and sortValue, so a render walked the full membership list once per cell. Build the user_id -> membership map once per team_memberships change and read from it instead. * perf(ui): page the member tables instead of rendering every row MemberTable rendered one row per member, so a team or an org with a couple of thousand members paid for every row on each render. Its DataTable now runs client-side pagination, like the other list tables in the dashboard. * fix(ui): keep the member table on its page when the roster is refetched * test(ui): cover a team member's spend and budget refreshing after a refetch --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
1b1ddef1b2
commit
ecbea93381
5 changed files with 221 additions and 21 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -160,6 +160,66 @@ describe("MemberTable sorting", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("MemberTable paging", () => {
|
||||
const roster = (): Member[] =>
|
||||
Array.from({ length: 30 }, (_, index) => ({
|
||||
user_id: `u-${index}`,
|
||||
user_email: `member${index}@example.com`,
|
||||
user_alias: `Member ${String(index).padStart(2, "0")}`,
|
||||
role: index === 0 ? "admin" : "user",
|
||||
}));
|
||||
|
||||
const renderRoster = () => {
|
||||
const props = { members: roster(), canEdit: true, onEdit: vi.fn(), onDelete: vi.fn() };
|
||||
const view = renderWithProviders(<MemberTable {...props} />);
|
||||
return { props, view };
|
||||
};
|
||||
|
||||
const goToSecondPage = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByTestId("pagination-next"));
|
||||
expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
|
||||
};
|
||||
|
||||
it("stays on the current page when the roster is refetched", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { props, view } = renderRoster();
|
||||
await goToSecondPage(user);
|
||||
|
||||
view.rerender(<MemberTable {...props} members={roster()} />);
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
|
||||
|
||||
expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the first page when the sort order changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRoster();
|
||||
await goToSecondPage(user);
|
||||
|
||||
await user.click(screen.getByTestId("sort-header-user_email"));
|
||||
|
||||
expect(await screen.findByText("Showing 1-25 of 30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the first page when the search or the role filter changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRoster();
|
||||
await goToSecondPage(user);
|
||||
|
||||
search("example.com");
|
||||
expect(await screen.findByText("Showing 1-25 of 30")).toBeInTheDocument();
|
||||
|
||||
search("");
|
||||
await goToSecondPage(user);
|
||||
await user.click(screen.getByTestId("datatable-filters-trigger"));
|
||||
await user.click(screen.getByTestId("filter-role"));
|
||||
await user.click(await screen.findByRole("option", { name: "user" }));
|
||||
await user.click(screen.getByTestId("filter-drawer-apply"));
|
||||
|
||||
expect(await screen.findByText("Showing 1-25 of 29")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemberTable role filter", () => {
|
||||
it("shows only members with the chosen role and clears on reset", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ColumnDef, ColumnFiltersState } from "@tanstack/react-table";
|
||||
import type { ColumnDef, ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { Crown, Info, User, UserPlus } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
DataTableFilterField,
|
||||
DataTableSortHeader,
|
||||
DataTableToolbar,
|
||||
DEFAULT_PAGE_SIZE_OPTIONS,
|
||||
} from "@/components/shared/DataTable";
|
||||
import { StatusBadge } from "@/components/shared/table_cells";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -44,6 +45,8 @@ export interface MemberTableProps {
|
|||
|
||||
const ALL_ROLES = "all";
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "user_alias", desc: false }];
|
||||
|
||||
export const memberRowId = (member: Member): string => member.user_id ?? member.user_email ?? JSON.stringify(member);
|
||||
|
||||
export const memberRoleOptions = (members: readonly Member[]): string[] =>
|
||||
|
|
@ -230,8 +233,21 @@ export default function MemberTable({
|
|||
}: MemberTableProps) {
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: DEFAULT_PAGE_SIZE_OPTIONS[0],
|
||||
});
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const backToFirstPage =
|
||||
<T,>(onChange: OnChangeFn<T>): OnChangeFn<T> =>
|
||||
(updaterOrValue) => {
|
||||
onChange(updaterOrValue);
|
||||
setPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||
};
|
||||
const changeSearch = backToFirstPage(setGlobalFilter);
|
||||
|
||||
const columnDeps: MemberColumnDeps = {
|
||||
canEdit,
|
||||
onEdit,
|
||||
|
|
@ -260,13 +276,17 @@ export default function MemberTable({
|
|||
data={members}
|
||||
columns={columns}
|
||||
getRowId={memberRowId}
|
||||
paginationMode="client"
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
sortingMode="client"
|
||||
defaultSorting={[{ id: "user_alias", desc: false }]}
|
||||
sorting={sorting}
|
||||
onSortingChange={backToFirstPage(setSorting)}
|
||||
filterMode="client"
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={setColumnFilters}
|
||||
onColumnFiltersChange={backToFirstPage(setColumnFilters)}
|
||||
globalFilter={globalFilter}
|
||||
onGlobalFilterChange={setGlobalFilter}
|
||||
onGlobalFilterChange={changeSearch}
|
||||
noDataMessage={
|
||||
<span className="text-muted-foreground">
|
||||
{isNarrowed ? "No members match your search or filters" : emptyText ?? "No data"}
|
||||
|
|
@ -277,7 +297,7 @@ export default function MemberTable({
|
|||
<DataTableToolbar
|
||||
table={table}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
onSearchChange={changeSearch}
|
||||
searchPlaceholder="Search by name, email, or user ID"
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
showViewOptions={false}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,39 @@ test("should display empty state when organization has no members", async () =>
|
|||
});
|
||||
});
|
||||
|
||||
test("keeps the members pager on its page when an admin opens a member's edit dialog", async () => {
|
||||
const members = Array.from({ length: 30 }, (_, index) => ({
|
||||
user_id: `user${index + 1}`,
|
||||
user_email: `user${index + 1}@example.com`,
|
||||
user_role: "internal_user",
|
||||
spend: 0,
|
||||
user: { user_alias: `alias${String(index + 1).padStart(2, "0")}` },
|
||||
}));
|
||||
mockUseOrganization.mockReturnValue({ data: { ...mockOrg, members }, isLoading: false } as unknown as ReturnType<
|
||||
typeof useOrganization
|
||||
>);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<OrganizationInfoView
|
||||
organizationId="org_123"
|
||||
onClose={() => {}}
|
||||
accessToken="test-token"
|
||||
is_org_admin={true}
|
||||
is_proxy_admin={false}
|
||||
userModels={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Members" }));
|
||||
await user.click(screen.getByTestId("pagination-next"));
|
||||
expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
|
||||
|
||||
await user.click(within(screen.getByRole("row", { name: /alias27/ })).getByTestId("edit-member"));
|
||||
|
||||
expect(screen.getByText("Showing 26-30 of 30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should display team aliases when teams are available", async () => {
|
||||
const orgWithTeams = {
|
||||
...mockOrg,
|
||||
|
|
|
|||
|
|
@ -316,6 +316,91 @@ describe("TeamMembersComponent", () => {
|
|||
expect(screen.getByText("Jul 15, 2026")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows each member the values from its own membership row", () => {
|
||||
const baseMembership = createMockTeamData().team_memberships[0];
|
||||
const teamData = createMockTeamData({
|
||||
team_memberships: [
|
||||
{
|
||||
...baseMembership,
|
||||
spend: 1.25,
|
||||
total_spend: 10,
|
||||
litellm_budget_table: { ...baseMembership.litellm_budget_table, max_budget: 250 },
|
||||
},
|
||||
{
|
||||
...baseMembership,
|
||||
user_id: "user2@test.com",
|
||||
spend: 7.5,
|
||||
total_spend: 20,
|
||||
litellm_budget_table: {
|
||||
...baseMembership.litellm_budget_table,
|
||||
max_budget: 2500,
|
||||
rpm_limit: 7,
|
||||
tpm_limit: 70,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
teamData={teamData}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
onMemberSpendReset={mockOnMemberSpendReset}
|
||||
onMemberBudgetReset={mockOnMemberBudgetReset}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user1Row = screen.getByRole("row", { name: /user1@test\.com/ });
|
||||
expect(within(user1Row).getByText("$1.25")).toBeInTheDocument();
|
||||
expect(within(user1Row).getByText("$10.00")).toBeInTheDocument();
|
||||
expect(within(user1Row).getByText("$250.00")).toBeInTheDocument();
|
||||
expect(within(user1Row).getByText("100 RPM / 10000 TPM")).toBeInTheDocument();
|
||||
|
||||
const user2Row = screen.getByRole("row", { name: /user2@test\.com/ });
|
||||
expect(within(user2Row).getByText("$7.50")).toBeInTheDocument();
|
||||
expect(within(user2Row).getByText("$20.00")).toBeInTheDocument();
|
||||
expect(within(user2Row).getByText("$2,500.00")).toBeInTheDocument();
|
||||
expect(within(user2Row).getByText("7 RPM / 70 TPM")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a member's new spend and budget once the team is refetched", () => {
|
||||
const baseMembership = createMockTeamData().team_memberships[0];
|
||||
const teamWith = (spend: number, maxBudget: number) =>
|
||||
createMockTeamData({
|
||||
team_memberships: [
|
||||
{
|
||||
...baseMembership,
|
||||
spend,
|
||||
litellm_budget_table: { ...baseMembership.litellm_budget_table, max_budget: maxBudget },
|
||||
},
|
||||
],
|
||||
});
|
||||
const renderTab = (teamData: TeamData) => (
|
||||
<TeamMembersComponent
|
||||
teamData={teamData}
|
||||
canEditTeam={false}
|
||||
handleMemberDelete={mockHandleMemberDelete}
|
||||
onMemberSpendReset={mockOnMemberSpendReset}
|
||||
onMemberBudgetReset={mockOnMemberBudgetReset}
|
||||
setSelectedEditMember={mockSetSelectedEditMember}
|
||||
setIsEditMemberModalVisible={mockSetIsEditMemberModalVisible}
|
||||
setIsAddMemberModalVisible={mockSetIsAddMemberModalVisible}
|
||||
/>
|
||||
);
|
||||
|
||||
const { rerender } = renderWithProviders(renderTab(teamWith(1.25, 250)));
|
||||
rerender(renderTab(teamWith(3.5, 400)));
|
||||
|
||||
const row = screen.getByRole("row", { name: /user1@test\.com/ });
|
||||
expect(within(row).getByText("$3.50")).toBeInTheDocument();
|
||||
expect(within(row).getByText("$400.00")).toBeInTheDocument();
|
||||
expect(within(row).queryByText("$1.25")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display formatted budget and Unlimited for member with no budget", () => {
|
||||
renderWithProviders(
|
||||
<TeamMembersComponent
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { toast } from "@/lib/toast";
|
|||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { useMemo, useState, type ComponentProps } from "react";
|
||||
import { TeamData, TeamMemberBudgetSource, TeamMembership } from "./TeamInfo";
|
||||
|
||||
const BUDGET_SOURCE_LABELS: Record<Exclude<TeamMemberBudgetSource, "none">, string> = {
|
||||
|
|
@ -74,6 +74,14 @@ export default function TeamMemberTab({
|
|||
const { mutate: resetMemberBudget, isPending: isResettingBudget } = useResetTeamMemberBudget();
|
||||
const teamDefaultBudget = teamData.team_info.team_member_budget_table?.max_budget ?? null;
|
||||
|
||||
const membershipsByUserId = useMemo(
|
||||
() => new Map(teamData.team_memberships.map((membership) => [membership.user_id, membership])),
|
||||
[teamData.team_memberships],
|
||||
);
|
||||
|
||||
const findMembership = (userId: string | null): TeamMembership | undefined =>
|
||||
userId ? membershipsByUserId.get(userId) : undefined;
|
||||
|
||||
const formatNumber = (value: number | null): string => {
|
||||
if (value === null || value === undefined) return "0";
|
||||
|
||||
|
|
@ -95,32 +103,28 @@ export default function TeamMemberTab({
|
|||
|
||||
const getUserCurrentCycleSpend = (userId: string | null): number => {
|
||||
if (!userId) return 0;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return membership?.spend ?? 0;
|
||||
return findMembership(userId)?.spend ?? 0;
|
||||
};
|
||||
|
||||
const getUserTotalSpend = (userId: string | null): number => {
|
||||
if (!userId) return 0;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return membership?.total_spend ?? 0;
|
||||
return findMembership(userId)?.total_spend ?? 0;
|
||||
};
|
||||
|
||||
const getUserBudgetSource = (userId: string | null): TeamMemberBudgetSource => {
|
||||
if (!userId) return "none";
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return membership?.budget_source ?? "none";
|
||||
return findMembership(userId)?.budget_source ?? "none";
|
||||
};
|
||||
|
||||
const getUserBudget = (userId: string | null): number | null => {
|
||||
if (!userId) return null;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return membership?.litellm_budget_table?.max_budget ?? teamDefaultBudget;
|
||||
return findMembership(userId)?.litellm_budget_table?.max_budget ?? teamDefaultBudget;
|
||||
};
|
||||
|
||||
// Helper function to get rate limits for a user
|
||||
const getUserRateLimits = (userId: string | null): string => {
|
||||
if (!userId) return "No Limits";
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
const membership = findMembership(userId);
|
||||
const rpmLimit = membership?.litellm_budget_table?.rpm_limit;
|
||||
const tpmLimit = membership?.litellm_budget_table?.tpm_limit;
|
||||
|
||||
|
|
@ -139,15 +143,13 @@ export default function TeamMemberTab({
|
|||
|
||||
const getUserAllowedModels = (userId: string | null): string[] | null => {
|
||||
if (!userId) return null;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
const models = membership?.litellm_budget_table?.allowed_models;
|
||||
const models = findMembership(userId)?.litellm_budget_table?.allowed_models;
|
||||
return models && models.length > 0 ? models : null;
|
||||
};
|
||||
|
||||
const getUserBudgetReset = (userId: string | null): string | null => {
|
||||
if (!userId) return null;
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === userId);
|
||||
return membership?.litellm_budget_table?.budget_reset_at ?? null;
|
||||
return findMembership(userId)?.litellm_budget_table?.budget_reset_at ?? null;
|
||||
};
|
||||
|
||||
const extraColumns: NonNullable<ComponentProps<typeof MemberTable>["extraColumns"]> = [
|
||||
|
|
@ -312,7 +314,7 @@ export default function TeamMemberTab({
|
|||
members={teamData.team_info.members_with_roles}
|
||||
canEdit={canEditTeam}
|
||||
onEdit={(record) => {
|
||||
const membership = teamData.team_memberships.find((tm) => tm.user_id === record.user_id);
|
||||
const membership = findMembership(record.user_id);
|
||||
setSelectedEditMember(
|
||||
seedMemberBudgetFields(
|
||||
record,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue