From e35f5d2b738f5ddb4d3ccd0fbed4e6159157a3cd Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 13 Jul 2026 17:47:14 -0700 Subject: [PATCH] feat(ui): rebuild the Teams table on the shared DataTable (#33128) * feat(ui): rebuild the Teams table on the shared DataTable The Your Teams tab moves off the Ant Design table onto the shared DataTable that the Virtual Keys page uses, following the new dashboard design. It gains server-side sort, pagination and filtering, a toolbar with a filter drawer and a columns menu, and a per-row actions menu Sorting is wired only to the columns /v2/team/list can actually order by (team_alias, created_at); Spend / Budget and Updated stay unsorted because the endpoint silently ignores those fields. The design's "Created by" column is dropped since the team object has no such field, and the drawer's "Has keys" filter is dropped for the same reason. The Resources cell shows members, models and keys as colored pills, and the actions menu keeps the existing Edit, Copy team ID and Delete behaviors, with Edit and Delete gated to Admin The teams grid gets its own unit tests in TeamsPage/TeamsTable.test.tsx. Teams.tsx keeps the create-team modal, delete modal, detail view and tabs, now refreshing the list through React Query invalidation instead of a manual refetch * fix(ui): match Teams loading skeletons to the rendered row height The default twoLine and chips skeleton shapes rendered the Team and Resources cells shorter than the loaded row (a real row measures 55px, the old skeleton ~49px), so the loading state looked visibly squat. Give the Team column a custom renderSkeleton that mirrors the two-line IdentityCell (measured 54px) and the Resources column one that mirrors the pills, and mark the hidden Rate Limits column as two-line so it matches when shown * fix(ui): keep team admins' Members tab by deriving is_team_admin from the selected team The redesign computed is_team_admin from useTeam(selectedTeamId), but that hook returns teamInfoCall's nested { team_info: { members_with_roles } } shape, so the top-level members_with_roles read was always undefined and is_team_admin was always false. For a non-proxy-admin team admin that hid the Members, Member Permissions and Settings tabs in the team detail view, which broke the team-admin add/remove member e2e tests. Pass the Team object up from the table instead (/v2/team/list returns it with a top-level members_with_roles), matching the pre-redesign behavior; proxy admins were unaffected because is_proxy_admin already granted access Also point the Delete-a-team e2e at the new kebab: open the row actions menu, then click Delete team, rather than clicking the old inline delete icon --- .../e2e_tests/tests/proxy-admin/teams.spec.ts | 4 +- ui/litellm-dashboard/eslint-suppressions.json | 4 +- .../app/(dashboard)/hooks/teams/useTeams.ts | 18 + .../src/components/Teams.test.tsx | 695 ++---------------- ui/litellm-dashboard/src/components/Teams.tsx | 576 ++------------- .../components/TeamsPage/TeamsTable.test.tsx | 330 +++++++++ .../src/components/TeamsPage/TeamsTable.tsx | 201 +++++ .../components/TeamsPage/teamTableColumns.tsx | 287 ++++++++ .../components/key_team_helpers/key_list.tsx | 2 + .../src/components/ui/dropdown-menu.tsx | 254 +++++++ 10 files changed, 1211 insertions(+), 1160 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx create mode 100644 ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx create mode 100644 ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx create mode 100644 ui/litellm-dashboard/src/components/ui/dropdown-menu.tsx diff --git a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/teams.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/teams.spec.ts index b30bb8aca7b..e7f67d7367f 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/teams.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/proxy-admin/teams.spec.ts @@ -96,7 +96,9 @@ test.describe("Proxy Admin - Teams", () => { const teamRow = page.locator("tr", { hasText: E2E_TEAM_DELETE_ALIAS }).first(); await expect(teamRow).toBeVisible({ timeout: 10_000 }); - await teamRow.locator("svg, img").last().click(); + // Actions live in a kebab menu: open it, then click "Delete team". + await teamRow.locator('[data-testid^="team-actions-"]').click(); + await page.getByTestId("team-action-delete").click(); const modal = page.locator(".ant-modal:visible"); await expect(modal).toBeVisible({ timeout: 5_000 }); diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index e4058888e7f..fa2287bdaef 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1643,13 +1643,13 @@ }, "src/components/Teams.tsx": { "no-nested-ternary": { - "count": 4 + "count": 2 }, "no-restricted-imports": { "count": 1 }, "react-hooks/set-state-in-effect": { - "count": 4 + "count": 3 } }, "src/components/ToolDetail.tsx": { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts index 490e5e3dad1..f532c44ffd7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts @@ -84,6 +84,24 @@ export const teamListCall = async ( } }; +export const teamsTableKeys = createQueryKeys("teamsTable"); + +export const useTeamsTable = ( + page: number, + pageSize: number, + options: TeamListCallOptions = {}, +): UseQueryResult => { + const { accessToken } = useAuthorized(); + + return useQuery({ + queryKey: teamsTableKeys.list({ page, limit: pageSize, ...options }), + queryFn: async () => await teamListCall(accessToken!, page, pageSize, options), + enabled: Boolean(accessToken), + staleTime: 30000, + placeholderData: keepPreviousData, + }); +}; + const teamKeys = createQueryKeys("teams"); export const useTeams = (): UseQueryResult => { const { accessToken, userId, userRole } = useAuthorized(); diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index ebddf291d12..f885b582dd2 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -1,15 +1,24 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key"; import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking"; import Teams from "./Teams"; -import { teamListCall } from "@/app/(dashboard)/hooks/teams/useTeams"; const mockTeamInfoView = vi.fn(); const mockUseOrganizations = vi.fn(); +// The teams grid is unit-tested in TeamsPage/TeamsTable.test.tsx. Here we stub it and drive its callbacks +// directly so we can test the Teams shell wiring (delete modal, detail view) without the real DataTable. +let mockTeamsTableProps: any = null; +vi.mock("./TeamsPage/TeamsTable", () => ({ + TeamsTable: (props: any) => { + mockTeamsTableProps = props; + return
; + }, +})); + vi.mock("./networking", () => ({ teamCreateCall: vi.fn(), teamDeleteCall: vi.fn(), @@ -19,8 +28,9 @@ vi.mock("./networking", () => ({ getPoliciesList: vi.fn().mockResolvedValue({ policies: [] }), })); +// Teams invalidates teamsTableKeys on mutations; the selected team is passed up from the table. vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ - teamListCall: vi.fn().mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 0 }), + teamsTableKeys: { all: ["teamsTable"] }, })); vi.mock("./molecules/notifications_manager", () => ({ @@ -116,6 +126,21 @@ vi.mock("./common_components/AccessGroupSelector", () => ({ ), })); +const baseTableTeam = { + team_id: "1", + team_alias: "Test Team", + organization_id: "org-123", + models: ["gpt-4"], + max_budget: 100, + budget_duration: "1d", + tpm_limit: 1000, + rpm_limit: 1000, + created_at: new Date().toISOString(), + keys: [], + members_with_roles: [], + spend: 0, +}; + const createQueryClient = () => { return new QueryClient({ defaultOptions: { @@ -131,10 +156,16 @@ const renderWithQueryClient = (component: React.ReactElement) => { return render({component}); }; +// Re-establish safe defaults before every test (clearAllMocks keeps return values, so restore them here). +beforeEach(() => { + mockTeamsTableProps = null; +}); + describe("Teams - handleCreate organization handling", () => { beforeEach(() => { vi.clearAllMocks(); mockTeamInfoView.mockClear(); + mockTeamsTableProps = null; vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); @@ -142,7 +173,6 @@ describe("Teams - handleCreate organization handling", () => { }); it("should not include organization_id when it's an empty string", async () => { - const mockAccessToken = "test-token"; const formValues: Record = { team_alias: "Test Team", organization_id: "", // Empty string @@ -168,7 +198,6 @@ describe("Teams - handleCreate organization handling", () => { models: [], }; - // Simulate the handleCreate logic let organizationId = formValues?.organization_id || null; if (organizationId === "" || typeof organizationId !== "string") { formValues.organization_id = null; @@ -182,11 +211,10 @@ describe("Teams - handleCreate organization handling", () => { it("should trim and keep valid organization_id string", async () => { const formValues: Record = { team_alias: "Test Team", - organization_id: " org-123 ", // String with whitespace + organization_id: " org-123 ", models: [], }; - // Simulate the handleCreate logic let organizationId = formValues?.organization_id || null; if (organizationId === "" || typeof organizationId !== "string") { formValues.organization_id = null; @@ -204,7 +232,6 @@ describe("Teams - handleCreate organization handling", () => { models: [], }; - // Simulate the handleCreate logic let organizationId = formValues?.organization_id || null; if (organizationId === "" || typeof organizationId !== "string") { formValues.organization_id = null; @@ -223,7 +250,6 @@ describe("Teams - handleCreate organization handling", () => { max_budget: 100, }; - // Simulate the handleCreate logic let organizationId = formValues?.organization_id || null; if (organizationId === "" || typeof organizationId !== "string") { formValues.organization_id = null; @@ -231,18 +257,13 @@ describe("Teams - handleCreate organization handling", () => { formValues.organization_id = organizationId.trim(); } - // Verify the structure expect(formValues).toEqual({ team_alias: "Test Team", organization_id: null, models: ["gpt-4"], max_budget: 100, }); - - // Verify we're not sending an empty string expect(formValues.organization_id).not.toBe(""); - - // Verify it's explicitly null, not undefined expect(formValues.organization_id).toBeNull(); }); @@ -259,7 +280,6 @@ describe("Teams - handleCreate organization handling", () => { models: [], }; - // Simulate the handleCreate logic with currentOrg fallback let organizationId = formValues?.organization_id || currentOrg?.organization_id; if (organizationId === "" || typeof organizationId !== "string") { formValues.organization_id = null; @@ -270,204 +290,27 @@ describe("Teams - handleCreate organization handling", () => { expect(formValues.organization_id).toBe("fallback-org-id"); }); - it("should not include organizations as an empty array in the request payload", async () => { - const mockTeamCreateCall = vi.mocked(teamCreateCall); - const mockAccessToken = "test-token"; - - const formValues = { - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - organizations: [], // This should never be sent - }; - - // Remove organizations key if it's empty - if (Array.isArray(formValues.organizations) && formValues.organizations.length === 0) { - delete (formValues as any).organizations; - } - - // Verify organizations key is removed - expect(formValues).not.toHaveProperty("organizations"); - expect(formValues).toEqual({ - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - }); - }); - - it("should handle organization_id validation for org admins", () => { - // This test simulates the validation that should happen for org admins - const isOrgAdmin = true; - const formValues: Record = { - team_alias: "Test Team", - // organization_id is missing/undefined - }; - - // For org admins, organization_id should be required - const hasOrganization = - formValues.organization_id !== undefined && - formValues.organization_id !== null && - formValues.organization_id !== ""; - - if (isOrgAdmin && !hasOrganization) { - // This should trigger validation error - expect(hasOrganization).toBe(false); - } - }); - - it("should allow null organization_id for global admins", () => { - const isAdmin = true; - const formValues: Record = { - team_alias: "Test Team", - organization_id: null, - models: [], - }; - - // Global admins can create teams without an organization - if (isAdmin) { - expect(formValues.organization_id).toBeNull(); - // This is valid for admins - } - }); - - it("should ensure organization_id is never an empty list", () => { - const invalidFormValues: Record = { - team_alias: "Test Team", - organization_id: [], // Wrong type - should be string or null - }; - - // Type check: organization_id should never be an array - expect(Array.isArray(invalidFormValues.organization_id)).toBe(true); - - // Correct it to null - if (Array.isArray(invalidFormValues.organization_id)) { - invalidFormValues.organization_id = null; - } - - expect(invalidFormValues.organization_id).toBeNull(); - expect(Array.isArray(invalidFormValues.organization_id)).toBe(false); - }); - - it("should clear the delete modal when the cancel button is clicked", async () => { + it("opens the delete modal when the table's delete action fires", async () => { mockUseOrganizations.mockReturnValue({ data: [] }); - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); renderWithQueryClient(); - await waitFor(() => { - expect(screen.getByTestId("delete-team-button")).toBeInTheDocument(); - }); - const deleteTeamButton = screen.getByTestId("delete-team-button"); - act(() => { - fireEvent.click(deleteTeamButton); + + await waitFor(() => expect(mockTeamsTableProps).not.toBeNull()); + await act(async () => { + mockTeamsTableProps.onDeleteTeam(baseTableTeam); }); + expect(screen.getByText("Delete Team?")).toBeInTheDocument(); }); }); -describe("Teams - empty state", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - it("should display empty state message when teams array is empty", async () => { - vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("No teams yet")).toBeInTheDocument(); - }); - expect( - screen.getByText("Create your first team to organize members and manage access to models."), - ).toBeInTheDocument(); - }); - - it("should display empty state message when teams is null", async () => { - vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("No teams yet")).toBeInTheDocument(); - }); - expect( - screen.getByText("Create your first team to organize members and manage access to models."), - ).toBeInTheDocument(); - }); - - it("should not display empty state when teams array has items", async () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("Test Team")).toBeInTheDocument(); - }); - expect(screen.queryByText("No teams yet")).not.toBeInTheDocument(); - expect( - screen.queryByText("Create your first team to organize members and manage access to models."), - ).not.toBeInTheDocument(); - }); -}); - describe("Teams - helper functions", () => { describe("getAdminOrganizations", () => { it("should return all organizations for Admin role", () => { const organizations = [ - { - organization_id: "org-1", - organization_alias: "Org 1", - models: [], - members: [], - }, - { - organization_id: "org-2", - organization_alias: "Org 2", - models: [], - members: [], - }, + { organization_id: "org-1", organization_alias: "Org 1", models: [], members: [] }, + { organization_id: "org-2", organization_alias: "Org 2", models: [], members: [] }, ]; - // Simulate getAdminOrganizations logic for Admin const userRole = "Admin"; const result = userRole === "Admin" ? organizations : []; @@ -477,7 +320,6 @@ describe("Teams - helper functions", () => { it("should return only org_admin organizations for Org Admin role", () => { const userID = "user-123"; - const userRole = "Org Admin"; const organizations = [ { organization_id: "org-1", @@ -499,7 +341,6 @@ describe("Teams - helper functions", () => { }, ]; - // Simulate getAdminOrganizations logic const result = organizations.filter((org) => org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), ); @@ -519,7 +360,6 @@ describe("Teams - helper functions", () => { }, ]; - // Simulate getAdminOrganizations logic const result = organizations.filter((org) => org.members?.some((member) => member.user_id === userID && member.user_role === "org_admin"), ); @@ -531,8 +371,7 @@ describe("Teams - helper functions", () => { describe("canCreateOrManageTeams", () => { it("should return true for Admin role", () => { const userRole = "Admin"; - const result = userRole === "Admin"; - expect(result).toBe(true); + expect(userRole === "Admin").toBe(true); }); it("should return true for org_admin in any organization", () => { @@ -577,6 +416,7 @@ describe("Teams - helper functions", () => { describe("Teams - premium props", () => { beforeEach(() => { + vi.clearAllMocks(); mockTeamInfoView.mockClear(); vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); @@ -584,38 +424,14 @@ describe("Teams - premium props", () => { mockUseOrganizations.mockReturnValue({ data: [] }); }); - it("passes premiumUser flag to TeamInfoView", async () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "team-123456789", - team_alias: "Premium Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); + it("passes premiumUser flag to TeamInfoView when a team is opened", async () => { + const premiumTeam = { ...baseTableTeam, team_id: "team-123456789", team_alias: "Premium Team" }; renderWithQueryClient(); - const teamIdElement = await screen.findByText("team-123456789"); - act(() => { - fireEvent.click(teamIdElement); - }); + await waitFor(() => expect(mockTeamsTableProps).not.toBeNull()); + act(() => mockTeamsTableProps.onSelectTeam(premiumTeam)); await waitFor(() => expect(mockTeamInfoView).toHaveBeenCalled()); - expect(mockTeamInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ premiumUser: true })); }); }); @@ -627,114 +443,22 @@ describe("Teams - Default Team Settings tab visibility", () => { }); it("should show Default Team Settings tab for Admin role", () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); renderWithQueryClient(); - expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument(); }); it("should show Default Team Settings tab for proxy_admin role", () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); renderWithQueryClient(); - expect(screen.getByRole("tab", { name: "Default Team Settings" })).toBeInTheDocument(); }); it("should not show Default Team Settings tab for proxy_admin_viewer role", () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); renderWithQueryClient(); - expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument(); }); it("should not show Default Team Settings tab for Admin Viewer role", () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); renderWithQueryClient(); - expect(screen.queryByRole("tab", { name: "Default Team Settings" })).not.toBeInTheDocument(); }); }); @@ -761,7 +485,6 @@ describe("Teams - access_group_ids in team create", () => { }); it("should pass access_group_ids to teamCreateCall when creating team", async () => { - vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); renderWithQueryClient(); const createButton = screen.getAllByRole("button", { name: /create team/i })[0]; @@ -773,25 +496,19 @@ describe("Teams - access_group_ids in team create", () => { expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); }); - const teamNameInput = screen.getByLabelText(/team name/i); - fireEvent.change(teamNameInput, { target: { value: "Test Team" } }); + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Test Team" } }); + fireEvent.change(screen.getByTestId("create-team-models-select"), { target: { value: "gpt-4" } }); - const modelsInput = screen.getByTestId("create-team-models-select"); - fireEvent.change(modelsInput, { target: { value: "gpt-4" } }); - - const additionalSettingsAccordion = screen.getByText("Additional Settings"); - fireEvent.click(additionalSettingsAccordion); + fireEvent.click(screen.getByText("Additional Settings")); await waitFor(() => { expect(screen.getByTestId("access-group-selector")).toBeInTheDocument(); }); - const accessGroupInput = screen.getByTestId("access-group-selector"); - fireEvent.change(accessGroupInput, { target: { value: "ag-1,ag-2" } }); + fireEvent.change(screen.getByTestId("access-group-selector"), { target: { value: "ag-1,ag-2" } }); const createTeamSubmitButtons = screen.getAllByRole("button", { name: /create team/i }); - const createTeamSubmitButton = createTeamSubmitButtons[createTeamSubmitButtons.length - 1]; - fireEvent.click(createTeamSubmitButton); + fireEvent.click(createTeamSubmitButtons[createTeamSubmitButtons.length - 1]); await waitFor(() => { expect(teamCreateCall).toHaveBeenCalledWith( @@ -814,9 +531,6 @@ describe("Teams - models dropdown options", () => { }); it("should not render all-proxy-models option in models select", async () => { - vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4", "gpt-3.5-turbo"]); - - vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); renderWithQueryClient(); await waitFor(() => { @@ -831,207 +545,7 @@ describe("Teams - models dropdown options", () => { await waitFor(() => { expect(screen.getByLabelText(/models/i)).toBeInTheDocument(); }); - const allProxyModelsOption = screen.queryByText("All Proxy Models"); - expect(allProxyModelsOption).not.toBeInTheDocument(); - }); -}); - -describe("Teams - organization alias display", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - it("should display organization alias instead of organization id", async () => { - const mockOrganizations = [ - { - organization_id: "org-123", - organization_alias: "Test Organization", - budget_id: "budget-1", - metadata: {}, - models: [], - spend: 0, - model_spend: {}, - created_at: new Date().toISOString(), - created_by: "user-1", - updated_at: new Date().toISOString(), - updated_by: "user-1", - litellm_budget_table: null, - teams: null, - users: null, - members: null, - }, - ]; - - mockUseOrganizations.mockReturnValue({ data: mockOrganizations }); - - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("Test Organization")).toBeInTheDocument(); - }); - expect(screen.queryByText("org-123")).not.toBeInTheDocument(); - }); - - it("should display organization id when alias is not found", async () => { - mockUseOrganizations.mockReturnValue({ data: [] }); - - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-unknown", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("org-unknown")).toBeInTheDocument(); - }); - }); - - it("should display N/A when organization_id is null", async () => { - mockUseOrganizations.mockReturnValue({ data: [] }); - - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Test Team", - organization_id: null, - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - renderWithQueryClient(); - - await waitFor(() => { - // When organization_id is null, the table shows "—" in the Organization column - expect(screen.getAllByText("—").length).toBeGreaterThan(0); - }); - }); -}); - -describe("Teams - Resources column keys badge", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - it("renders keys_count from the v2 payload in the Resources badge", async () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "1", - team_alias: "Team With Keys", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [], - keys_count: 3, - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - const { container } = renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("Team With Keys")).toBeInTheDocument(); - }); - const cyanTag = container.querySelector(".ant-tag-cyan"); - expect(cyanTag).not.toBeNull(); - expect(cyanTag?.textContent).toContain("3"); - }); - - it("falls back to keys.length when keys_count is absent", async () => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [ - { - team_id: "2", - team_alias: "Legacy Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - keys: [{ token: "t1" }, { token: "t2" }], - members_with_roles: [], - spend: 0, - }, - ], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); - const { container } = renderWithQueryClient(); - - await waitFor(() => { - expect(screen.getByText("Legacy Team")).toBeInTheDocument(); - }); - const cyanTag = container.querySelector(".ant-tag-cyan"); - expect(cyanTag).not.toBeNull(); - expect(cyanTag?.textContent).toContain("2"); + expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument(); }); }); @@ -1042,39 +556,16 @@ describe("Teams - delete team warning copy", () => { }); const openDeleteModal = async (team: any) => { - vi.mocked(teamListCall).mockResolvedValue({ - teams: [team], - total: 1, - page: 1, - page_size: 100, - total_pages: 1, - }); renderWithQueryClient(); - await waitFor(() => { - expect(screen.getByTestId("delete-team-button")).toBeInTheDocument(); - }); - act(() => { - fireEvent.click(screen.getByTestId("delete-team-button")); + await waitFor(() => expect(mockTeamsTableProps).not.toBeNull()); + await act(async () => { + mockTeamsTableProps.onDeleteTeam(team); }); expect(screen.getByText("Delete Team?")).toBeInTheDocument(); }; - const baseTeam = { - team_id: "1", - team_alias: "Test Team", - organization_id: "org-123", - models: ["gpt-4"], - max_budget: 100, - budget_duration: "1d", - tpm_limit: 1000, - rpm_limit: 1000, - created_at: new Date().toISOString(), - members_with_roles: [], - spend: 0, - }; - it("warns that the team's models are deleted when the team has keys", async () => { - await openDeleteModal({ ...baseTeam, keys: [], keys_count: 5 }); + await openDeleteModal({ ...baseTableTeam, keys: [], keys_count: 5 }); expect(screen.getByText(/Warning: This team has 5 keys associated with it/i)).toHaveTextContent( /along with any models created for this team/i, @@ -1085,7 +576,7 @@ describe("Teams - delete team warning copy", () => { }); it("still warns about model deletion in the confirmation message when the team has no keys", async () => { - await openDeleteModal({ ...baseTeam, keys: [], keys_count: 0 }); + await openDeleteModal({ ...baseTableTeam, keys: [], keys_count: 0 }); expect(screen.queryByText(/Warning: This team has/i)).not.toBeInTheDocument(); expect(screen.getByText(/Are you sure you want to delete this team/i)).toHaveTextContent( @@ -1101,7 +592,6 @@ describe("Teams - LIT-2530 organization stays optional for proxy admin with a si vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]); vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); - vi.mocked(teamListCall).mockResolvedValue({ teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }); vi.mocked(teamCreateCall).mockResolvedValue({ team_id: "new-team-1", team_alias: "No Org Team", @@ -1142,68 +632,3 @@ describe("Teams - LIT-2530 organization stays optional for proxy admin with a si }); }); }); - -describe("Teams - search debounce", () => { - const emptyTeamList = { teams: [], total: 0, page: 1, page_size: 100, total_pages: 1 }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue([]); - vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]); - vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] }); - vi.mocked(teamListCall).mockResolvedValue(emptyTeamList); - mockUseOrganizations.mockReturnValue({ data: [] }); - }); - - afterEach(() => { - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - }); - - const typeSearch = (value: string) => { - fireEvent.change(screen.getByPlaceholderText("Search teams by name or ID..."), { target: { value } }); - }; - - it("fires a single search request with the last value only after the wait elapses", async () => { - renderWithQueryClient(); - await act(async () => {}); - vi.mocked(teamListCall).mockClear(); - - act(() => { - typeSearch("a"); - typeSearch("ab"); - typeSearch("abc"); - }); - expect(teamListCall).not.toHaveBeenCalled(); - - act(() => { - vi.advanceTimersByTime(299); - }); - expect(teamListCall).not.toHaveBeenCalled(); - - await act(async () => { - vi.advanceTimersByTime(1); - }); - - expect(teamListCall).toHaveBeenCalledTimes(1); - expect(teamListCall).toHaveBeenCalledWith("test-token", 1, 10, expect.objectContaining({ search: "abc" })); - }); - - it("does not fire the search request when unmounted mid-wait", async () => { - const { unmount } = renderWithQueryClient(); - await act(async () => {}); - vi.mocked(teamListCall).mockClear(); - - act(() => { - typeSearch("abc"); - }); - unmount(); - - await act(async () => { - vi.advanceTimersByTime(300); - }); - - expect(teamListCall).not.toHaveBeenCalled(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index c2d3ef2ad63..57a5e1cbff0 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -3,40 +3,16 @@ import AvailableTeamsPanel from "@/components/team/available_teams"; import TeamInfoView from "@/components/team/TeamInfo"; import TeamSSOSettings from "@/components/TeamSSOSettings"; import { isProxyAdminRole } from "@/utils/roles"; -import { InfoCircleOutlined, PlusOutlined, TeamOutlined, ReloadOutlined } from "@ant-design/icons"; +import { InfoCircleOutlined } from "@ant-design/icons"; import { Accordion, AccordionBody, AccordionHeader, TextInput } from "@tremor/react"; -import { - Button, - Card, - Flex, - Form, - Input, - Layout, - Modal, - Pagination, - Progress, - Select, - Space, - Switch, - Table, - Tabs, - Tag, - theme, - Tooltip, - Typography, - message, -} from "antd"; -import type { ColumnsType } from "antd/es/table"; -import type { SorterResult } from "antd/es/table/interface"; -import { KeyIcon, LayersIcon, SearchIcon, UsersIcon } from "lucide-react"; -import React, { useEffect, useMemo, useState } from "react"; -import { useDebouncer } from "@tanstack/react-pacer/debouncer"; -import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; -import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner"; -import { DateCell, IdCell } from "@/components/shared/table_cells"; -import OrganizationDropdown from "./common_components/OrganizationDropdown"; -import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; -import { teamListCall as v2TeamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { Button, Form, Input, Layout, Modal, Select, Switch, Tabs, theme, Tooltip, Typography } from "antd"; +import { Plus, Users } from "lucide-react"; +import React, { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { PageHeader } from "@/components/shared/PageHeader"; +import { Button as UIButton } from "@/components/ui/button"; +import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { TeamsTable } from "./TeamsPage/TeamsTable"; import AccessGroupSelector from "./common_components/AccessGroupSelector"; import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector"; import AgentSelector from "./agent_management/AgentSelector"; @@ -47,7 +23,7 @@ import { fetchAvailableModelsForTeamOrKey, unfurlWildcardModelsInList, } from "./key_team_helpers/fetch_available_models_team_key"; -import type { KeyResponse, Team } from "./key_team_helpers/key_list"; +import type { Team } from "./key_team_helpers/key_list"; import MCPServerSelector from "./mcp_server_management/MCPServerSelector"; import MCPToolPermissions from "./mcp_server_management/MCPToolPermissions"; import NotificationsManager from "./molecules/notifications_manager"; @@ -63,13 +39,6 @@ interface TeamProps { premiumUser?: boolean; } -interface FilterState { - search: string; - organization_id: string; - sort_by: string; - sort_order: "asc" | "desc"; -} - interface EditTeamModalProps { visible: boolean; onCancel: () => void; @@ -77,21 +46,10 @@ interface EditTeamModalProps { onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted } -import { updateExistingKeys } from "@/utils/dataUtils"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; -import { Member, teamCreateCall } from "./networking"; +import { teamCreateCall } from "./networking"; import { ModelSelect } from "./ModelSelect/ModelSelect"; -interface TeamInfo { - members_with_roles: Member[]; -} - -interface PerTeamInfo { - keys: KeyResponse[]; - keys_count: number; - team_info: TeamInfo; -} - const getOrganizationModels = (organization: Organization | null, userModels: string[]) => { let tempModelsToPick = []; @@ -166,82 +124,17 @@ const getOrganizationAlias = ( const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser = false }) => { const { data: organizationsData } = useOrganizations(); const organizations = organizationsData ?? null; - const [teams, setTeams] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [fetchError, setFetchError] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(10); - const [totalTeams, setTotalTeams] = useState(0); - const [currentOrg, setCurrentOrg] = useState(null); + const queryClient = useQueryClient(); + const refreshTeams = () => queryClient.invalidateQueries({ queryKey: teamsTableKeys.all }); + const [currentOrg] = useState(null); const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); - const [filters, setFilters] = useState({ - search: "", - organization_id: "", - sort_by: "created_at", - sort_order: "desc", - }); - const [isSearching, setIsSearching] = useState(false); - - const fetchTeamsV2 = async ( - opts: { - page?: number; - size?: number; - sortBy?: string; - sortOrder?: string; - organizationID?: string; - search?: string; - } = {}, - ) => { - if (!accessToken) return; - const page = opts.page ?? currentPage; - const size = opts.size ?? pageSize; - const sortBy = opts.sortBy ?? filters.sort_by; - const sortOrder = opts.sortOrder ?? filters.sort_order; - const organizationID = opts.organizationID ?? filters.organization_id; - const search = opts.search ?? filters.search; - - setIsLoading(true); - setFetchError(null); - try { - const response: TeamsResponse = await v2TeamListCall(accessToken, page, size, { - organizationID: organizationID || null, - search: search || null, - userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null, - sortBy: sortBy || null, - sortOrder: sortOrder || null, - }); - setTeams(response.teams ?? []); - setTotalTeams(response.total ?? 0); - } catch (err: any) { - setFetchError(err?.message || "Failed to fetch teams"); - } finally { - setIsLoading(false); - } - }; - - const searchDebouncer = useDebouncer( - async (value: string) => { - try { - setFilters((prev) => ({ ...prev, search: value })); - setCurrentPage(1); - await fetchTeamsV2({ page: 1, search: value }); - } finally { - setIsSearching(false); - } - }, - { wait: DEBOUNCE_WAIT_MS }, - ); - - useEffect(() => { - fetchTeamsV2(); - }, [accessToken]); const [form] = Form.useForm(); const [memberForm] = Form.useForm(); const [value, setValue] = useState(""); const [editModalVisible, setEditModalVisible] = useState(false); - const [selectedTeam, setSelectedTeam] = useState(null); + const [selectedTeam, setSelectedTeam] = useState(null); const [selectedTeamId, setSelectedTeamId] = useState(null); const [editTeam, setEditTeam] = useState(false); @@ -252,7 +145,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [teamToDelete, setTeamToDelete] = useState(null); const [modelsToPick, setModelsToPick] = useState([]); - const [perTeamInfo, setPerTeamInfo] = useState>({}); const [isTeamDeleting, setIsTeamDeleting] = useState(false); // Add this state near the other useState declarations const [guardrailsList, setGuardrailsList] = useState([]); @@ -339,30 +231,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser fetchMcpAccessGroups(); }, [accessToken]); - useEffect(() => { - const fetchTeamInfo = () => { - if (!teams) return; - - const newPerTeamInfo = teams.reduce( - (acc, team) => { - acc[team.team_id] = { - keys: team.keys || [], - keys_count: team.keys_count ?? team.keys?.length ?? 0, - team_info: { - members_with_roles: team.members_with_roles || [], - }, - }; - return acc; - }, - {} as Record, - ); - - setPerTeamInfo(newPerTeamInfo); - }; - - fetchTeamInfo(); - }, [teams]); - const handleOk = () => { setIsTeamModalVisible(false); form.resetFields(); @@ -400,14 +268,14 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser }; const confirmDelete = async () => { - if (teamToDelete == null || teams == null || accessToken == null) { + if (teamToDelete == null || accessToken == null) { return; } try { setIsTeamDeleting(true); await teamDeleteCall(accessToken, teamToDelete.team_id); - await fetchTeamsV2(); + await refreshTeams(); NotificationsManager.success("Team deleted successfully"); } catch (error) { NotificationsManager.fromBackend("Error deleting the team: " + error); @@ -439,13 +307,11 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser }; fetchUserModels(); - }, [accessToken, userID, userRole, teams]); + }, [accessToken, userID, userRole]); const handleCreate = async (formValues: Record) => { try { if (accessToken != null) { - const newTeamAlias = formValues?.team_alias; - const existingTeamAliases = teams?.map((t) => t.team_alias) ?? []; let organizationId = formValues?.organization_id || currentOrg?.organization_id; if (organizationId === "" || typeof organizationId !== "string") { formValues.organization_id = null; @@ -453,11 +319,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser formValues.organization_id = organizationId.trim(); } - // Remove guardrails from top level since it's now in metadata - if (existingTeamAliases.includes(newTeamAlias)) { - throw new Error(`Team alias ${newTeamAlias} already exists, please pick another alias`); - } - NotificationsManager.info("Creating Team"); // Handle logging settings in metadata @@ -579,10 +440,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser await teamCreateCall(accessToken, formValues); NotificationsManager.success("Team created"); - await fetchTeamsV2({ - page: currentPage, - size: pageSize, - }); + await refreshTeams(); form.resetFields(); setLoggingSettings([]); setModelAliases({}); @@ -609,343 +467,31 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser return false; }; - const handleSearchChange = (value: string) => { - setIsSearching(true); - searchDebouncer.maybeExecute(value); - }; - - const handleFilterChange = async (key: keyof FilterState, value: string) => { - const newFilters = { ...filters, [key]: value }; - setFilters(newFilters); - setCurrentPage(1); - if (!accessToken) return; - try { - const response: TeamsResponse = await v2TeamListCall(accessToken, 1, pageSize, { - organizationID: newFilters.organization_id || null, - search: newFilters.search || null, - userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null, - sortBy: newFilters.sort_by || null, - sortOrder: newFilters.sort_order || null, - }); - setTeams(response.teams ?? []); - setTotalTeams(response.total ?? 0); - } catch (error) { - console.error("Error fetching teams:", error); - } - }; - - const handleFilterReset = () => { - searchDebouncer.cancel(); - setIsSearching(false); - const resetFilters: FilterState = { - search: "", - organization_id: "", - sort_by: "created_at", - sort_order: "desc", - }; - setFilters(resetFilters); - setCurrentPage(1); - fetchTeamsV2({ page: 1, organizationID: "", search: "", sortBy: "created_at", sortOrder: "desc" }); - }; - const { token } = theme.useToken(); - const { Title, Text } = Typography; + const { Text } = Typography; const { Content } = Layout; - const handleRetry = () => { - fetchTeamsV2(); - }; - - const handleTableSort = ( - _pagination: unknown, - _filters: unknown, - sorter: SorterResult | SorterResult[], - ) => { - const s = Array.isArray(sorter) ? sorter[0] : sorter; - const sortBy = s.order ? (s.columnKey as string) : "created_at"; - const sortOrder = s.order === "ascend" ? "asc" : s.order === "descend" ? "desc" : "desc"; - setFilters((prev) => ({ ...prev, sort_by: sortBy, sort_order: sortOrder })); - fetchTeamsV2({ sortBy, sortOrder }); - }; - - const teamColumns: ColumnsType = useMemo( - () => [ - { - title: "Team ID", - dataIndex: "team_id", - key: "team_id", - width: 170, - ellipsis: true, - render: (id: string) => ( - setSelectedTeamId(teamId)} dataTestId="team-id-cell" /> - ), - }, - { - title: "Team Alias", - dataIndex: "team_alias", - key: "team_alias", - ellipsis: true, - sorter: true, - render: (alias: string | undefined) => ( - - {alias || ( - - — - - )} - - ), - }, - { - title: "Organization", - key: "organization", - width: 160, - ellipsis: true, - render: (_: unknown, record: Team) => { - const orgAlias = getOrganizationAlias(record.organization_id, organizations); - return record.organization_id ? ( - - {orgAlias} - - ) : ( - - ); - }, - }, - { - title: "Resources", - key: "resources", - width: 240, - render: (_: unknown, record: Team) => { - const memberCount = perTeamInfo?.[record.team_id]?.team_info?.members_with_roles?.length ?? 0; - const modelCount = record.models?.length ?? 0; - const keyCount = perTeamInfo?.[record.team_id]?.keys_count ?? 0; - return ( - - - - - - {memberCount} - - - - - - - - {modelCount} - - - - - - - - {keyCount} - - - - - ); - }, - }, - { - title: "Spend / Budget", - key: "spend", - width: 200, - sorter: true, - render: (_: unknown, record: Team) => { - const spendVal = record.spend ?? 0; - const budgetVal = record.max_budget; - const spendStr = `$${spendVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; - const budgetStr = - budgetVal != null - ? `$${budgetVal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` - : "Unlimited"; - const percent = budgetVal != null && budgetVal > 0 ? Math.min((spendVal / budgetVal) * 100, 100) : null; - return ( - - - {spendStr} - - {" / "} - {budgetStr} - - - {percent != null && ( - = 90 ? "#ff4d4f" : percent >= 70 ? "#faad14" : "#1677ff"} - style={{ marginBottom: 0 }} - /> - )} - - ); - }, - }, - { - title: "Created", - dataIndex: "created_at", - key: "created_at", - width: 130, - ellipsis: true, - sorter: true, - render: (date: string | undefined) => , - }, - { - title: "Actions", - key: "actions", - width: 120, - align: "right" as const, - render: (_: unknown, record: Team) => ( - - { - navigator.clipboard - .writeText(record.team_id) - .then(() => message.success("Team ID copied")) - .catch(() => message.error("Failed to copy")); - }} - /> - {userRole === "Admin" && ( - <> - { - setSelectedTeamId(record.team_id); - setEditTeam(true); - }} - /> - handleDelete(record)} - /> - - )} - - ), - }, - ], - [userRole, perTeamInfo, organizations], - ); - - const displayTeams = useMemo(() => teams ?? [], [teams]); - - const renderTeamsContent = () => { - if (isLoading) { - return ( - - - - ); - } - - if (fetchError) { - return ( - - - Failed to load teams - - - {fetchError} - - - - ); - } - - return ( - - columns={teamColumns} - dataSource={displayTeams} - rowKey="team_id" - pagination={false} - onChange={handleTableSort} - locale={{ - emptyText: ( -
- -
- No teams yet -
-
- - Create your first team to organize members and manage access to models. - -
- {canCreateOrManageTeams(userRole, userID, organizations) && ( - - )} -
- ), - }} - scroll={{ x: 1000 }} - size="middle" - /> - ); - }; - const tabItems = [ { key: "your-teams", label: "Your Teams", children: ( <> - - - - } - suffix={isSearching ? : null} - placeholder="Search teams by name or ID..." - onChange={(e) => handleSearchChange(e.target.value)} - allowClear - style={{ maxWidth: 400 }} - /> - handleFilterChange("organization_id", value || "")} - loading={isLoading} - /> - - { - setCurrentPage(page); - setPageSize(size); - fetchTeamsV2({ page, size }); - }} - size="small" - showTotal={(total) => `${total} teams`} - showSizeChanger - pageSizeOptions={["10", "20", "50"]} - /> - - - {renderTeamsContent()} - + { + setSelectedTeam(team); + setSelectedTeamId(team.team_id); + setEditTeam(false); + }} + onEditTeam={(team) => { + setSelectedTeam(team); + setSelectedTeamId(team.team_id); + setEditTeam(true); + }} + onDeleteTeam={handleDelete} + /> = ({ accessToken, userID, userRole, premiumUser {selectedTeamId ? ( { - setTeams((teams) => { - if (teams == null) { - return teams; - } - return teams.map((team) => { - if (data.team_id === team.team_id) { - return updateExistingKeys(team, data); - } - return team; - }); - }); - fetchTeamsV2(); + onUpdate={() => { + refreshTeams(); }} onClose={() => { + setSelectedTeam(null); setSelectedTeamId(null); setEditTeam(false); }} accessToken={accessToken} - is_team_admin={is_team_admin(teams?.find((team) => team.team_id === selectedTeamId))} + is_team_admin={is_team_admin(selectedTeam)} is_proxy_admin={userRole == "Admin"} userModels={userModels} editTeam={editTeam} @@ -1023,25 +559,21 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser /> ) : ( <> - - - - <TeamOutlined style={{ marginRight: 8 }} /> - Teams - - Manage teams, members, and their access to models and budgets - - {canCreateOrManageTeams(userRole, userID, organizations) && ( - - )} - +
+ } + title="Teams" + subtitle="Manage teams, members, and their access to models and budgets" + actions={ + canCreateOrManageTeams(userRole, userID, organizations) ? ( + setIsTeamModalVisible(true)} data-testid="create-team-button"> + + Create Team + + ) : undefined + } + /> +
diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx new file mode 100644 index 00000000000..9469be12128 --- /dev/null +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.test.tsx @@ -0,0 +1,330 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, MockedFunction, vi } from "vitest"; + +import { renderWithProviders } from "../../../tests/test-utils"; +import { Team } from "../key_team_helpers/key_list"; +import { TeamsResponse, useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { TeamsTable } from "./TeamsTable"; + +// Resolve debounced values synchronously so an applied filter lands in the useTeamsTable query within the test tick. +vi.mock("@tanstack/react-pacer/debouncer", async () => { + const React = await vi.importActual("react"); + return { + useDebouncedValue: (value: unknown) => [value, { cancel: vi.fn(), flush: vi.fn() }], + useDebouncedState: (initial: unknown) => { + const [value, setValue] = React.useState(initial); + return [value, setValue, { cancel: vi.fn(), flush: vi.fn() }]; + }, + }; +}); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: vi.fn(() => ({ + accessToken: "test-token", + userId: "test-user", + userRole: "Admin", + premiumUser: true, + token: "test-token", + })), +})); + +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useTeamsTable: vi.fn(), + teamsTableKeys: { all: ["teamsTable"] }, +})); + +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ + data: [{ organization_id: "org-1", organization_alias: "Test Organization" }], + }), +})); + +const mockTeam: Team = { + team_id: "team-1", + team_alias: "Acme Team", + models: ["gpt-4", "gpt-3.5-turbo", "claude-3", "claude-3-5-sonnet"], + max_budget: 100, + budget_duration: "1mo", + tpm_limit: 5000, + rpm_limit: 500, + organization_id: "org-1", + created_at: "2024-10-01T10:00:00Z", + updated_at: "2024-11-01T10:00:00Z", + keys: [], + keys_count: 3, + members_with_roles: [ + { user_id: "u1", user_email: "a@x.com", role: "admin" }, + { user_id: "u2", user_email: "b@x.com", role: "user" }, + ] as unknown as Team["members_with_roles"], + spend: 42.5, +}; + +const mockUseTeamsTable = useTeamsTable as MockedFunction; + +const teamsResult = (teams: Team[], data: Partial = {}, extra: Record = {}) => + ({ + data: { + teams, + total: teams.length, + page: 1, + page_size: 50, + total_pages: 1, + ...data, + } as TeamsResponse, + isPending: false, + isFetching: false, + isError: false, + refetch: vi.fn(), + ...extra, + }) as any; + +const noop = () => {}; + +const renderTable = (props: Partial> = {}) => + renderWithProviders( + , + ); + +const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" })); +const lastOptions = () => mockUseTeamsTable.mock.calls[mockUseTeamsTable.mock.calls.length - 1][2] ?? {}; + +beforeEach(() => { + vi.clearAllMocks(); + mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam])); +}); + +it("renders a team row with alias, organization, and spend/budget", async () => { + renderTable(); + + await waitFor(() => { + expect(screen.getByText("Acme Team")).toBeInTheDocument(); + expect(screen.getByText("Test Organization")).toBeInTheDocument(); + expect(screen.getByText("$42.5000")).toBeInTheDocument(); + expect(screen.getByText("of $100")).toBeInTheDocument(); + }); +}); + +it("renders the Resources cell with member, model, and key counts", () => { + renderTable(); + + expect(screen.getByTitle("2 members")).toBeInTheDocument(); + expect(screen.getByTitle("4 models")).toBeInTheDocument(); + expect(screen.getByTitle("3 keys")).toBeInTheDocument(); +}); + +it("shows 'No teams found' when the list is empty", () => { + mockUseTeamsTable.mockReturnValue(teamsResult([])); + renderTable(); + expect(screen.getByText("No teams found")).toBeInTheDocument(); +}); + +it("shows a loading state on initial load and hides the data", () => { + mockUseTeamsTable.mockReturnValue(teamsResult([], {}, { data: null, isPending: true, isFetching: true })); + renderTable(); + + expect(screen.getByText("Loading teams...")).toBeInTheDocument(); + expect(screen.queryByText("Acme Team")).not.toBeInTheDocument(); +}); + +describe("sort contract – only backend-sortable columns are sortable", () => { + it("requests the default created_at descending sort on first render", () => { + renderTable(); + expect(lastOptions()).toMatchObject({ sortBy: "created_at", sortOrder: "desc" }); + }); + + it("sorts by the backend team_alias field (not the label) when the Team header is clicked", async () => { + renderTable(); + fireEvent.click(screen.getByText("Team").closest("button") as HTMLElement); + + await waitFor(() => { + expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: "team_alias" })); + }); + }); + + it("does not make Spend / Budget sortable (the backend rejects sort_by=spend)", () => { + renderTable(); + expect(screen.getByText("Spend / Budget").closest("button")).toBeNull(); + // Team and Created are the only sortable headers. + expect(screen.getByText("Team").closest("button")).not.toBeNull(); + expect(screen.getByText("Created").closest("button")).not.toBeNull(); + }); +}); + +describe("server-side filtering maps controls to the right query params", () => { + it("sends no filter params when nothing is applied", () => { + renderTable(); + expect(lastOptions()).toMatchObject({ organizationID: undefined, team_alias: undefined, teamID: undefined }); + }); + + it("threads an applied Team alias filter into the query", async () => { + renderTable(); + openFilters(); + + fireEvent.change(await screen.findByPlaceholderText(/Enter team alias/), { target: { value: "acme" } }); + fireEvent.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => { + expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ team_alias: "acme" })); + }); + }); + + it("threads an applied Team ID filter into the query", async () => { + renderTable(); + openFilters(); + + fireEvent.change(await screen.findByPlaceholderText(/Enter team ID/), { target: { value: "team-xyz" } }); + fireEvent.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => { + expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ teamID: "team-xyz" })); + }); + }); + + it("threads the toolbar search into the search param", async () => { + renderTable(); + fireEvent.change(screen.getByTestId("datatable-search"), { target: { value: "platform" } }); + + await waitFor(() => { + expect(mockUseTeamsTable).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ search: "platform" })); + }); + }); +}); + +describe("non-admin scoping", () => { + it("scopes the list to the current user when the role is not an admin role", () => { + renderTable({ userRole: "Internal User", userID: "user-42" }); + expect(lastOptions()).toMatchObject({ userID: "user-42" }); + }); + + it("does not scope by user for the Admin role", () => { + renderTable({ userRole: "Admin", userID: "admin-1" }); + expect(lastOptions()).toMatchObject({ userID: undefined }); + }); +}); + +describe("row actions", () => { + it("opens the team detail when the team cell is clicked", () => { + const onSelectTeam = vi.fn(); + renderTable({ onSelectTeam }); + + fireEvent.click(screen.getByText("Acme Team")); + expect(onSelectTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" })); + }); + + it("offers Edit and Delete to an Admin and wires them to the callbacks", async () => { + const onEditTeam = vi.fn(); + const onDeleteTeam = vi.fn(); + const user = userEvent.setup(); + renderTable({ userRole: "Admin", onEditTeam, onDeleteTeam }); + + await user.click(screen.getByTestId("team-actions-team-1")); + + await user.click(await screen.findByText("Edit team")); + expect(onEditTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" })); + + await user.click(screen.getByTestId("team-actions-team-1")); + await user.click(await screen.findByText("Delete team")); + expect(onDeleteTeam).toHaveBeenCalledWith(expect.objectContaining({ team_id: "team-1" })); + }); + + it("hides Edit and Delete from a non-admin, leaving only Copy team ID", async () => { + const user = userEvent.setup(); + renderTable({ userRole: "Internal User" }); + + await user.click(screen.getByTestId("team-actions-team-1")); + + expect(await screen.findByText("Copy team ID")).toBeInTheDocument(); + expect(screen.queryByText("Edit team")).not.toBeInTheDocument(); + expect(screen.queryByText("Delete team")).not.toBeInTheDocument(); + }); +}); + +describe("pagination total comes from the query response", () => { + it("shows the total count and page count from the response", async () => { + mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], { total: 137, total_pages: 3 })); + renderTable(); + + await waitFor(() => { + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 137"); + expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 3"); + }); + }); +}); + +describe("refresh control", () => { + it("calls refetch when clicked", () => { + const refetch = vi.fn(); + mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { refetch })); + renderTable(); + + fireEvent.click(screen.getByTestId("datatable-refresh")); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it("keeps rows visible but disables refresh while a background fetch is in flight", () => { + mockUseTeamsTable.mockReturnValue(teamsResult([mockTeam], {}, { isFetching: true })); + renderTable(); + + expect(screen.getByTestId("datatable-refresh")).toBeDisabled(); + expect(screen.getByText("Acme Team")).toBeInTheDocument(); + }); +}); + +describe("column rendering details", () => { + it("shows the organization alias when the id resolves, and the raw id when it does not", async () => { + mockUseTeamsTable.mockReturnValue( + teamsResult([ + { ...mockTeam, team_id: "a", organization_id: "org-1" }, + { ...mockTeam, team_id: "b", team_alias: "Orphan Team", organization_id: "org-unknown" }, + ]), + ); + renderTable(); + + await waitFor(() => { + expect(screen.getByText("Test Organization")).toBeInTheDocument(); + expect(screen.getByText("org-unknown")).toBeInTheDocument(); + }); + }); + + it("renders an em dash for a team with no organization", () => { + mockUseTeamsTable.mockReturnValue(teamsResult([{ ...mockTeam, organization_id: null as unknown as string }])); + renderTable(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("falls back to keys.length when keys_count is absent", () => { + mockUseTeamsTable.mockReturnValue( + teamsResult([ + { + ...mockTeam, + keys_count: undefined, + keys: [{ token: "t1" }, { token: "t2" }] as unknown as Team["keys"], + }, + ]), + ); + renderTable(); + expect(screen.getByTitle("2 keys")).toBeInTheDocument(); + }); +}); + +describe("hidden-by-default columns", () => { + it("hides Members, Models, Rate Limits, and Updated until toggled on", async () => { + const user = userEvent.setup(); + renderTable(); + + expect(screen.queryByText("Rate Limits")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Columns" })); + const menu = await screen.findByRole("menu"); + expect(within(menu).getByText("Rate Limits")).toBeInTheDocument(); + expect(within(menu).getByText("Updated")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx new file mode 100644 index 00000000000..3b75db52b16 --- /dev/null +++ b/ui/litellm-dashboard/src/components/TeamsPage/TeamsTable.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { useTeamsTable } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { + DataTable, + DataTableFilterDrawer, + DataTableFilterField, + DataTableToolbar, +} from "@/components/shared/DataTable"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { Input } from "@/components/ui/input"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; +import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; +import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import React, { useCallback, useMemo, useState } from "react"; + +import { Team } from "../key_team_helpers/key_list"; +import { getTeamTableColumns, TEAM_TABLE_HIDDEN_COLUMNS } from "./teamTableColumns"; + +interface TeamsTableProps { + userRole: string | null; + userID: string | null; + onSelectTeam: (team: Team) => void; + onEditTeam: (team: Team) => void; + onDeleteTeam: (team: Team) => void; +} + +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +const toSortOrder = (sorting: SortingState): "asc" | "desc" | undefined => { + const active = sorting[0]; + if (!active) return undefined; + return active.desc ? "desc" : "asc"; +}; + +const FILTER_LABELS: Record = { + org_id: "Organization", + alias: "Team alias", + team_id: "Team ID", +}; + +export function TeamsTable({ userRole, userID, onSelectTeam, onEditTeam, onDeleteTeam }: TeamsTableProps) { + const { data: fetchedOrganizations } = useOrganizations(); + const organizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]); + + const [sorting, setSorting] = useState(DEFAULT_SORTING); + const [tablePagination, setTablePagination] = useState({ pageIndex: 0, pageSize: 50 }); + const [columnFilters, setColumnFilters] = useState([]); + const [filtersOpen, setFiltersOpen] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); + + const getFilterValue = useCallback( + (columnId: string): string | undefined => { + const entry = columnFilters.find((filter) => filter.id === columnId); + return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined; + }, + [columnFilters], + ); + + const isAdminView = userRole === "Admin" || userRole === "Admin Viewer"; + + const teamListOptions = { + organizationID: getFilterValue("org_id"), + team_alias: getFilterValue("alias"), + teamID: getFilterValue("team_id"), + search: searchQuery.trim() || undefined, + userID: isAdminView ? undefined : userID ?? undefined, + sortBy: sorting[0]?.id, + sortOrder: toSortOrder(sorting), + }; + + const { + data: teamsResponse, + isPending: isLoading, + isFetching, + refetch, + } = useTeamsTable(tablePagination.pageIndex + 1, tablePagination.pageSize, teamListOptions); + + const teamList = useMemo(() => teamsResponse?.teams ?? [], [teamsResponse]); + const rowCount = teamsResponse?.total ?? 0; + + const handleSearchChange = useCallback((value: string) => { + setSearchInput(value); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); + + const handleSortingChange = useCallback>((updaterOrValue) => { + setSorting(updaterOrValue); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); + + const handleColumnFiltersChange = useCallback>((updaterOrValue) => { + setColumnFilters(updaterOrValue); + setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); + }, []); + + const columns = useMemo(() => { + const columnDeps = { organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam }; + return getTeamTableColumns(columnDeps); + }, [organizations, userRole, onSelectTeam, onEditTeam, onDeleteTeam]); + + const orgOptions = useMemo( + () => + organizations + .filter((org) => org.organization_id) + .map((org) => { + const id = org.organization_id as string; + return { label: org.organization_alias || id, value: id, sublabel: org.organization_alias ? id : undefined }; + }), + [organizations], + ); + + const formatFilterValue = useCallback( + (columnId: string, value: unknown): string => { + const raw = String(value); + if (columnId === "org_id") { + return organizations.find((org) => org.organization_id === raw)?.organization_alias || raw; + } + return raw; + }, + [organizations], + ); + + return ( + row.team_id} + defaultColumnVisibility={TEAM_TABLE_HIDDEN_COLUMNS} + sortingMode="server" + sorting={sorting} + onSortingChange={handleSortingChange} + paginationMode="server" + pagination={tablePagination} + onPaginationChange={setTablePagination} + rowCount={rowCount} + filterMode="server" + columnFilters={columnFilters} + onColumnFiltersChange={handleColumnFiltersChange} + enableColumnResizing + columnResizeMode="onChange" + isLoading={isLoading} + loadingMessage="Loading teams..." + noDataMessage="No teams found" + maxBodyHeight="calc(75vh - 210px)" + size="compact" + toolbar={(table) => ( + <> + refetch?.()} + isRefreshing={isFetching} + onOpenFilters={() => setFiltersOpen(true)} + filterLabels={FILTER_LABELS} + formatFilterValue={formatFilterValue} + /> + + {({ get, set }) => ( + <> + + set("org_id", value)} + placeholder="Select an organization…" + emptyText="No organizations found" + /> + + + set("alias", event.target.value)} + placeholder="Enter team alias…" + /> + + + set("team_id", event.target.value)} + placeholder="Enter team ID…" + /> + + + )} + + + )} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx b/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx new file mode 100644 index 00000000000..ecf7387ee83 --- /dev/null +++ b/ui/litellm-dashboard/src/components/TeamsPage/teamTableColumns.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, KeyRound, Layers, MoreHorizontal, Pencil, Trash2, Users } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdentityCell, SpendBudgetCell } from "@/components/shared/table_cells"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils"; + +import { Team } from "../key_team_helpers/key_list"; +import { Organization } from "../networking"; + +interface ResourceTone { + icon: typeof Users; + className: string; +} + +const RESOURCE_TONES: Record<"members" | "models" | "keys", ResourceTone> = { + members: { icon: Users, className: "bg-violet-50 text-violet-700 ring-violet-600/20" }, + models: { icon: Layers, className: "bg-sky-50 text-sky-700 ring-sky-600/20" }, + keys: { icon: KeyRound, className: "bg-emerald-50 text-emerald-700 ring-emerald-600/20" }, +}; + +const teamMemberCount = (team: Team): number => team.members_count ?? team.members_with_roles?.length ?? 0; +const teamModelCount = (team: Team): number => team.models?.length ?? 0; +const teamKeyCount = (team: Team): number => team.keys_count ?? team.keys?.length ?? 0; + +function ResourcesCell({ team }: { team: Team }) { + const items = [ + { key: "members" as const, label: "members", count: teamMemberCount(team) }, + { key: "models" as const, label: "models", count: teamModelCount(team) }, + { key: "keys" as const, label: "keys", count: teamKeyCount(team) }, + ]; + + return ( +
+ {items.map((item) => { + const tone = RESOURCE_TONES[item.key]; + const Icon = tone.icon; + return ( + + + {item.count} + + ); + })} +
+ ); +} + +function RateLimitLine({ label, value }: { label: string; value: number | null }) { + return ( +
+ {label} + {value != null ? formatNumberWithCommas(value) : "Unlimited"} +
+ ); +} + +interface TeamRowActionsProps { + team: Team; + canManage: boolean; + onEditTeam: (team: Team) => void; + onDeleteTeam: (team: Team) => void; +} + +function TeamRowActions({ team, canManage, onEditTeam, onDeleteTeam }: TeamRowActionsProps) { + const handleCopy = () => { + void copyToClipboard(team.team_id, "Team ID copied"); + }; + + return ( + + + + + + {canManage && ( + onEditTeam(team)} data-testid="team-action-edit"> + + Edit team + + )} + + + Copy team ID + + {canManage && ( + <> + + onDeleteTeam(team)} data-testid="team-action-delete"> + + Delete team + + + )} + + + ); +} + +interface TeamTableColumnsDeps { + organizations: Organization[]; + userRole: string | null; + onSelectTeam: (team: Team) => void; + onEditTeam: (team: Team) => void; + onDeleteTeam: (team: Team) => void; +} + +export const getTeamTableColumns = ({ + organizations, + userRole, + onSelectTeam, + onEditTeam, + onDeleteTeam, +}: TeamTableColumnsDeps): ColumnDef[] => { + const canManage = userRole === "Admin"; + + return [ + { + id: "team_alias", + accessorKey: "team_alias", + meta: { + title: "Team", + renderSkeleton: () => ( +
+ + +
+ ), + }, + header: ({ column }) => , + size: 260, + enableSorting: true, + cell: ({ row }) => { + const team = row.original; + const hasAlias = Boolean(team.team_alias); + return ( + onSelectTeam(team)} + /> + ); + }, + }, + { + id: "organization_alias", + accessorKey: "organization_id", + meta: { title: "Organization" }, + header: "Organization", + size: 160, + enableSorting: false, + cell: (info) => { + const orgId = info.getValue() as string | null; + if (!orgId) return ; + const org = organizations.find((o) => o.organization_id === orgId); + const displayValue = org?.organization_alias || orgId; + const width = info.cell.column.getSize(); + return ( + + {displayValue} + + ); + }, + }, + { + id: "resources", + meta: { + title: "Resources", + renderSkeleton: () => ( +
+ + + +
+ ), + }, + header: "Resources", + size: 210, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "spend", + accessorKey: "spend", + meta: { title: "Spend / Budget", skeleton: "meter" }, + header: "Spend / Budget", + size: 200, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "created_at", + accessorKey: "created_at", + meta: { title: "Created" }, + header: ({ column }) => , + size: 130, + enableSorting: true, + cell: (info) => , + }, + { + id: "members", + meta: { title: "Members" }, + header: "Members", + size: 110, + enableSorting: false, + cell: ({ row }) => {teamMemberCount(row.original)}, + }, + { + id: "models", + meta: { title: "Models" }, + header: "Models", + size: 100, + enableSorting: false, + cell: ({ row }) => {teamModelCount(row.original)}, + }, + { + id: "rate_limits", + meta: { title: "Rate Limits", skeleton: "twoLine" }, + header: "Rate Limits", + size: 140, + enableSorting: false, + cell: ({ row }) => ( +
+ + +
+ ), + }, + { + id: "updated_at", + accessorKey: "updated_at", + meta: { title: "Updated" }, + header: "Updated", + size: 130, + enableSorting: false, + cell: (info) => , + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 60, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, + ]; +}; + +export const TEAM_TABLE_HIDDEN_COLUMNS: Record = { + members: false, + models: false, + rate_limits: false, + updated_at: false, +}; diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index 285daa8f156..a3d1ad2c4db 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -12,8 +12,10 @@ export interface Team { rpm_limit: number | null; organization_id: string; created_at: string; + updated_at?: string | null; keys: KeyResponse[]; keys_count?: number; + members_count?: number; members_with_roles: Member[]; spend: number; access_group_ids?: string[]; diff --git a/ui/litellm-dashboard/src/components/ui/dropdown-menu.tsx b/ui/litellm-dashboard/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000000..03fefba7d19 --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,254 @@ +"use client"; + +import * as React from "react"; +import { Menu as MenuPrimitive } from "@base-ui/react/menu"; + +import { cn } from "@/lib/cva.config"; +import { ChevronRightIcon, CheckIcon } from "lucide-react"; + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return ; +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return ; +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return ; +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & Pick) { + return ( + + + + + + ); +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return ; +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +};