diff --git a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx index 64ffd8bb4df..7af2e442b90 100644 --- a/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MemberTable.tsx @@ -1,8 +1,8 @@ import { Member } from "@/components/networking"; import { CrownOutlined, InfoCircleOutlined, UserAddOutlined, UserOutlined } from "@ant-design/icons"; -import { Button, Space, Table, Tag, Tooltip, Typography } from "antd"; +import { Button, Pagination, Space, Table, Tag, Tooltip, Typography } from "antd"; import type { ColumnsType } from "antd/es/table"; -import React from "react"; +import React, { useState } from "react"; import TableIconActionButton from "./IconActionButton/TableIconActionButtons/TableIconActionButton"; const { Text } = Typography; @@ -18,6 +18,10 @@ export interface MemberTableProps { extraColumns?: ColumnsType; showDeleteForMember?: (member: Member) => boolean; emptyText?: string; + loading?: boolean; + /** When true, renders top-right pagination controls instead of the default antd bottom pagination. */ + withPagination?: boolean; + defaultPageSize?: number; } export default function MemberTable({ @@ -31,7 +35,17 @@ export default function MemberTable({ extraColumns = [], showDeleteForMember, emptyText, + loading, + withPagination = false, + defaultPageSize = 50, }: MemberTableProps) { + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(defaultPageSize); + + const total = members.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(page, totalPages); + const pagedMembers = withPagination ? members.slice((safePage - 1) * pageSize, safePage * pageSize) : members; const baseColumns: ColumnsType = [ { title: "User Email", @@ -104,14 +118,29 @@ export default function MemberTable({ return ( - - {members.length} Member{members.length !== 1 ? "s" : ""} - +
+ + {total} Member{total !== 1 ? "s" : ""} + + {withPagination && ( + { setPage(p); if (ps !== pageSize) { setPageSize(ps); setPage(1); } }} + /> + )} +
record.user_id ?? record.user_email ?? JSON.stringify(record)} pagination={false} + loading={loading} size="small" scroll={{ x: "max-content" }} locale={emptyText ? { emptyText } : undefined} diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx index ac0ae16a44f..a040cbe19ca 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -368,7 +368,69 @@ describe("TeamMembersComponent", () => { expect(screen.getAllByTestId("edit-member")).toHaveLength(2); }); - it("should hide action buttons when canEditTeam is false", () => { + it("should filter members by email search", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + const searchInput = screen.getByPlaceholderText(/search by email or user id/i); + await user.type(searchInput, "user1"); + + expect(screen.getAllByText("user1@test.com").length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText("user2@test.com")).not.toBeInTheDocument(); + }); + + it("should filter members by role", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + const roleSelect = screen.getByRole("combobox"); + await user.click(roleSelect); + await user.click(screen.getByText("Admin")); + + expect(screen.getAllByText("user2@test.com").length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText("user1@test.com")).not.toBeInTheDocument(); + }); + + it("should show all members when search is cleared", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + const searchInput = screen.getByPlaceholderText(/search by email or user id/i); + await user.type(searchInput, "user1"); + await user.clear(searchInput); + + expect(screen.getAllByText("user1@test.com").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("user2@test.com").length).toBeGreaterThanOrEqual(1); + }); + + renderWithProviders( void; } + export default function TeamMemberTab({ teamData, canEditTeam, @@ -27,80 +29,87 @@ export default function TeamMemberTab({ setIsEditMemberModalVisible, setIsAddMemberModalVisible, }: TeamMemberTabProps) { + const [searchText, setSearchText] = useState(""); + const [roleFilter, setRoleFilter] = useState(null); + + // O(1) lookup instead of O(n) find() per member per column + const membershipsMap = useMemo( + () => + new Map( + teamData.team_memberships + .filter((tm) => tm.user_id) + .map((tm) => [tm.user_id, tm]), + ), + [teamData.team_memberships], + ); + + const filteredMembers = useMemo(() => { + const q = searchText.trim().toLowerCase(); + return teamData.team_info.members_with_roles.filter((m) => { + if (roleFilter && m.role?.toLowerCase() !== roleFilter) return false; + if (!q) return true; + return ( + m.user_email?.toLowerCase().includes(q) || + m.user_id?.toLowerCase().includes(q) + ); + }); + }, [teamData.team_info.members_with_roles, searchText, roleFilter]); + const formatNumber = (value: number | null): string => { if (value === null || value === undefined) return "0"; - if (typeof value === "number") { - // Convert scientific notation to normal decimal const normalNumber = Number(value); - - // If it's a whole number, return it without decimals - if (normalNumber === Math.floor(normalNumber)) { - return normalNumber.toString(); - } - - // For decimal numbers, use toFixed and remove trailing zeros + if (normalNumber === Math.floor(normalNumber)) return normalNumber.toString(); return formatNumberWithCommas(normalNumber, 8).replace(/\.?0+$/, ""); } - return "0"; }; 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 membershipsMap.get(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 membershipsMap.get(userId)?.total_spend ?? 0; }; const getUserBudget = (userId: string | null): string | null => { if (!userId) return null; - const membership = teamData.team_memberships.find((tm) => tm.user_id === userId); - const maxBudget = membership?.litellm_budget_table?.max_budget; - if (maxBudget === null || maxBudget === undefined) { - return null; - } + const maxBudget = membershipsMap.get(userId)?.litellm_budget_table?.max_budget; + if (maxBudget === null || maxBudget === undefined) return null; return formatNumber(maxBudget); }; - // 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 = membershipsMap.get(userId); const rpmLimit = membership?.litellm_budget_table?.rpm_limit; const tpmLimit = membership?.litellm_budget_table?.tpm_limit; - const rpmText = rpmLimit ? `${formatNumber(rpmLimit)} RPM` : null; const tpmText = tpmLimit ? `${formatNumber(tpmLimit)} TPM` : null; - const limits = [rpmText, tpmText].filter(Boolean); return limits.length > 0 ? limits.join(" / ") : "No Limits"; }; + const getUserAllowedModels = (userId: string | null): string[] | null => { + if (!userId) return null; + const models = membershipsMap.get(userId)?.litellm_budget_table?.allowed_models; + return models && models.length > 0 ? models : null; + }; + + const getUserBudgetReset = (userId: string | null): string | null => { + if (!userId) return null; + return formatBudgetReset(membershipsMap.get(userId)?.litellm_budget_table?.budget_reset_at); + }; + const { data: uiSettingsData } = useUISettings(); const { userId, userRole } = useAuthorized(); const disableTeamAdminDeleteTeamUser = Boolean(uiSettingsData?.values?.disable_team_admin_delete_team_user); const isUserTeamAdmin = isUserTeamAdminForSingleTeam(teamData.team_info.members_with_roles, userId || ""); const isProxyAdmin = isProxyAdminRole(userRole || ""); - 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; - 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 formatBudgetReset(membership?.litellm_budget_table?.budget_reset_at); - }; - const extraColumns: ColumnsType = [ { title: ( @@ -202,31 +211,52 @@ export default function TeamMemberTab({ ]; return ( - { - const membership = teamData.team_memberships.find( - (tm) => tm.user_id === record.user_id - ); - const enhancedMember = { - ...record, - max_budget_in_team: membership?.litellm_budget_table?.max_budget || null, - tpm_limit: membership?.litellm_budget_table?.tpm_limit || null, - rpm_limit: membership?.litellm_budget_table?.rpm_limit || null, - allowed_models: membership?.litellm_budget_table?.allowed_models || [], - }; - setSelectedEditMember(enhancedMember); - setIsEditMemberModalVisible(true); - }} - onDelete={handleMemberDelete} - onAddMember={() => setIsAddMemberModalVisible(true)} - roleColumnTitle="Team Role" - roleTooltip="This role applies only to this team and is independent from the user's proxy-level role." - extraColumns={extraColumns} - showDeleteForMember={() => - isProxyAdmin || (canEditTeam && !isUserTeamAdmin) || (isUserTeamAdmin && !disableTeamAdminDeleteTeamUser) - } - /> + + + setSearchText(e.target.value)} + onSearch={(v) => setSearchText(v)} + /> +