From 879a287ba991bfcd4247942c49c3ff2889396c9a Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 20 Jul 2026 22:24:28 -0700 Subject: [PATCH] refactor(ui): migrate organizations table onto shared DataTable The organizations admin table was a hand-rolled tremor/antd table in a single snake_case file. This moves it onto the shared DataTable and cell library the other migrated tables use, splitting it into a data-owning OrganizationsPanel, a thin OrganizationsTable consumer, and a getOrganizationsTableColumns module The models column no longer uses a per-row accordion whose expand state lived in the parent; it renders the shared ModelsCell with truncation and a "+N more" tooltip, matching every other table with a models column. Row actions (Edit, Delete) move into a per-row overflow menu gated to proxy admins, while the detail view, create modal, and delete modal stay in the panel. The server-side org id / org alias search stays wired to the useOrganizations hook, and the table gains an initial-load skeleton plus a search-aware empty state. The dead sort_by / sort_order filter fields, the misnamed "Info" column that only ever showed a member count, and an unused refresh affordance are dropped; the default created_at descending sort is preserved --- ui/litellm-dashboard/eslint-suppressions.json | 5 - .../OrganizationFilters.test.tsx | 2 - .../organizations/OrganizationFilters.tsx | 2 - .../_components/OrganizationsPanel.test.tsx | 57 ++ .../_components/OrganizationsPanel.tsx | 299 ++++++++++ .../_components/OrganizationsTable.test.tsx | 188 ++++++ .../_components/OrganizationsTable.tsx | 75 +++ .../_components/OrganizationsTableColumns.tsx | 186 ++++++ .../_components/organizations.test.tsx | 39 -- .../_components/organizations.tsx | 535 ------------------ .../app/(dashboard)/organizations/page.tsx | 4 +- 11 files changed, 807 insertions(+), 585 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.test.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index f935af8907d..8e81dc55f31 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -697,11 +697,6 @@ "count": 1 } }, - "src/app/(dashboard)/organizations/_components/organizations.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx index 814625ff6be..37eeaf4c2af 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.test.tsx @@ -7,8 +7,6 @@ describe("OrganizationFilters", () => { const defaultFilters: FilterState = { org_id: "", org_alias: "", - sort_by: "", - sort_order: "asc", }; it("should render", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx index 5643a4bc51a..6ad2f00fdb0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/OrganizationFilters.tsx @@ -14,8 +14,6 @@ interface OrganizationFiltersProps { type FilterState = { org_id: string; org_alias: string; - sort_by: string; - sort_order: "asc" | "desc"; }; const OrganizationFilters = ({ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx new file mode 100644 index 00000000000..d381e5e65ca --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.test.tsx @@ -0,0 +1,57 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ + __esModule: true, + default: () => null, +})); +vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({ + __esModule: true, + default: () => null, +})); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ + accessToken: null, + userId: null, + userRole: null, + }), +})); +vi.mock("./OrganizationsTable", () => ({ + __esModule: true, + default: (props: { isLoading: boolean }) => ( +
isLoading:{String(props.isLoading)}
+ ), +})); + +import OrganizationsPanel from "./OrganizationsPanel"; + +const renderWithQueryClient = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render({ui}); +}; + +describe("OrganizationsPanel", () => { + it("gates non-premium users behind the enterprise notice", () => { + renderWithQueryClient(); + + expect(screen.getByText(/LiteLLM Enterprise feature/i)).toBeInTheDocument(); + expect(screen.queryByText("+ Create New Organization")).not.toBeInTheDocument(); + }); + + it("shows the create button for a premium admin", () => { + renderWithQueryClient(); + + expect(screen.getByText("+ Create New Organization")).toBeInTheDocument(); + }); + + it("resolves the loading skeleton to false when the query is disabled (no token)", () => { + renderWithQueryClient(); + + // A disabled React Query keeps isPending true forever; feeding isLoading avoids a stuck skeleton. + expect(screen.getByTestId("organizations-table")).toHaveTextContent("isLoading:false"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx new file mode 100644 index 00000000000..9f7e029a1d4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsPanel.tsx @@ -0,0 +1,299 @@ +import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; +import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels"; +import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { Form, Input, Modal, Select as Select2, Tooltip } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; +import React, { useState } from "react"; +import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; +import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { organizationCreateCall, organizationDeleteCall } from "@/components/networking"; +import OrganizationInfoView from "@/components/organization/organization_view"; +import NumericalInput from "@/components/shared/numerical_input"; +import { Button } from "@/components/ui/button"; +import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; + +import OrganizationsTable from "./OrganizationsTable"; + +interface OrganizationsPanelProps { + userRole: string; + accessToken: string | null; + premiumUser: boolean; +} + +const OrganizationsPanel: React.FC = ({ userRole, accessToken, premiumUser }) => { + const [selectedOrgId, setSelectedOrgId] = useState(null); + const [editOrg, setEditOrg] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [orgToDelete, setOrgToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [isOrgModalVisible, setIsOrgModalVisible] = useState(false); + const [form] = Form.useForm(); + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({ org_id: "", org_alias: "" }); + + const queryClient = useQueryClient(); + const { data: organizations = [], isLoading } = useOrganizations({ + org_id: filters.org_id, + org_alias: filters.org_alias, + }); + const { data: userModels = [] } = useUserModels(); + + const searchActive = Boolean(filters.org_id || filters.org_alias); + + const refetchOrganizations = () => queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }); + + const handleFilterChange = (key: keyof FilterState, value: string) => { + setFilters((previousFilters) => ({ ...previousFilters, [key]: value })); + }; + + const handleFilterReset = () => { + setFilters({ org_id: "", org_alias: "" }); + }; + + const handleDelete = (orgId: string | null) => { + if (!orgId) return; + + setOrgToDelete(orgId); + setIsDeleteModalOpen(true); + }; + + const confirmDelete = async () => { + if (!orgToDelete || !accessToken) return; + + try { + setIsDeleting(true); + await organizationDeleteCall(accessToken, orgToDelete); + NotificationsManager.success("Organization deleted successfully"); + + setIsDeleteModalOpen(false); + setOrgToDelete(null); + await refetchOrganizations(); + } catch (error) { + console.error("Error deleting organization:", error); + } finally { + setIsDeleting(false); + } + }; + + const cancelDelete = () => { + setIsDeleteModalOpen(false); + setOrgToDelete(null); + }; + + const handleCreate = async (values: any) => { + try { + if (!accessToken) return; + + // Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission + if ( + (values.allowed_vector_store_ids && values.allowed_vector_store_ids.length > 0) || + (values.allowed_mcp_servers_and_groups && + (values.allowed_mcp_servers_and_groups.servers?.length > 0 || + values.allowed_mcp_servers_and_groups.accessGroups?.length > 0)) + ) { + values.object_permission = {}; + if (values.allowed_vector_store_ids && values.allowed_vector_store_ids.length > 0) { + values.object_permission.vector_stores = values.allowed_vector_store_ids; + delete values.allowed_vector_store_ids; + } + if (values.allowed_mcp_servers_and_groups) { + if (values.allowed_mcp_servers_and_groups.servers?.length > 0) { + values.object_permission.mcp_servers = values.allowed_mcp_servers_and_groups.servers; + } + if (values.allowed_mcp_servers_and_groups.accessGroups?.length > 0) { + values.object_permission.mcp_access_groups = values.allowed_mcp_servers_and_groups.accessGroups; + } + delete values.allowed_mcp_servers_and_groups; + } + } + + await organizationCreateCall(accessToken, values); + NotificationsManager.success("Organization created successfully"); + setIsOrgModalVisible(false); + form.resetFields(); + await refetchOrganizations(); + } catch (error) { + console.error("Error creating organization:", error); + } + }; + + const handleCancel = () => { + setIsOrgModalVisible(false); + form.resetFields(); + }; + + if (!premiumUser) { + return ( +
+

+ This is a LiteLLM Enterprise feature, and requires a valid key to use. Get a trial key{" "} + + here + + . +

+
+ ); + } + + return ( +
+ {(userRole === "Admin" || userRole === "Org Admin") && ( + + )} + + {selectedOrgId ? ( + { + setSelectedOrgId(null); + setEditOrg(false); + }} + accessToken={accessToken} + is_org_admin={true} + is_proxy_admin={userRole === "Admin"} + userModels={userModels} + editOrg={editOrg} + /> + ) : ( + <> +

Click on an organization ID to view its details.

+ + { + setSelectedOrgId(organizationId); + setEditOrg(true); + }} + onDeleteClick={handleDelete} + /> + + )} + + +
+ + + + + form.setFieldValue("models", values)} + context="organization" + /> + + + + + + + + daily + weekly + monthly + + + + + + + + + + + Allowed Vector Stores{" "} + + + + + } + name="allowed_vector_store_ids" + className="mt-4" + help="Select vector stores this organization can access. Leave empty for access to all vector stores" + > + form.setFieldValue("allowed_vector_store_ids", values)} + value={form.getFieldValue("allowed_vector_store_ids")} + accessToken={accessToken || ""} + placeholder="Select vector stores (optional)" + /> + + + + Allowed MCP Servers{" "} + + + + + } + name="allowed_mcp_servers_and_groups" + className="mt-4" + help="Select MCP servers and access groups this organization can access." + > + form.setFieldValue("allowed_mcp_servers_and_groups", values)} + value={form.getFieldValue("allowed_mcp_servers_and_groups")} + accessToken={accessToken || ""} + placeholder="Select MCP servers and access groups (optional)" + /> + + + + + + +
+ +
+
+
+ + +
+ ); +}; + +export default OrganizationsPanel; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx new file mode 100644 index 00000000000..a06c5c885e3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx @@ -0,0 +1,188 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { Organization } from "@/components/networking"; + +import OrganizationsTable from "./OrganizationsTable"; + +const makeOrganization = (overrides: Partial = {}): Organization => ({ + organization_id: "org-alpha", + organization_alias: "Alpha", + budget_id: "budget-1", + metadata: {}, + models: [], + spend: 0, + model_spend: {}, + created_at: "2023-01-01T00:00:00Z", + created_by: "someone", + updated_at: "2023-01-01T00:00:00Z", + updated_by: "someone", + litellm_budget_table: null, + teams: null, + users: null, + members: null, + ...overrides, +}); + +const baseProps = { + isLoading: false, + userRole: "Admin", + searchActive: false, + onOrganizationClick: vi.fn(), + onEditClick: vi.fn(), + onDeleteClick: vi.fn(), +}; + +describe("OrganizationsTable", () => { + it("renders every column header", () => { + render(); + for (const header of [ + "Organization ID", + "Organization Name", + "Created", + "Spend (USD)", + "Budget (USD)", + "Models", + "TPM / RPM Limits", + "Members", + ]) { + expect(screen.getByText(header)).toBeInTheDocument(); + } + }); + + it("opens the detail view when the organization ID cell is clicked", async () => { + const user = userEvent.setup(); + const onOrganizationClick = vi.fn(); + render( + , + ); + + await user.click(screen.getByText("org-123")); + + expect(onOrganizationClick).toHaveBeenCalledWith("org-123"); + }); + + it("edits and deletes an organization through the ⋯ actions menu (admin)", async () => { + const user = userEvent.setup(); + const onEditClick = vi.fn(); + const onDeleteClick = vi.fn(); + render( + , + ); + + await user.click(screen.getByTestId("organization-actions-org-9")); + await user.click(await screen.findByTestId("organization-action-edit")); + expect(onEditClick).toHaveBeenCalledWith("org-9"); + + await user.click(screen.getByTestId("organization-actions-org-9")); + await user.click(await screen.findByTestId("organization-action-delete")); + expect(onDeleteClick).toHaveBeenCalledWith("org-9"); + }); + + it("hides the row actions menu from non-admins", () => { + render( + , + ); + + expect(screen.queryByTestId("organization-actions-org-9")).not.toBeInTheDocument(); + }); + + it("sorts by created_at descending by default", () => { + render( + , + ); + + const rows = screen.getAllByRole("row"); + // rows[0] is the header row; the newest organization must lead the body. + expect(within(rows[1]).getByText("Newer")).toBeInTheDocument(); + expect(within(rows[2]).getByText("Older")).toBeInTheDocument(); + }); + + it("renders budget, limits, members, and models for a fully-populated organization", () => { + render( + , + ); + + expect(screen.getByText("$100.00")).toBeInTheDocument(); + expect(screen.getByText("TPM: 1000")).toBeInTheDocument(); + expect(screen.getByText("RPM: 60")).toBeInTheDocument(); + expect(screen.getByText("3 Members")).toBeInTheDocument(); + // Five models, three visible -> the shared ModelsCell collapses the rest. + expect(screen.getByText("+2 more")).toBeInTheDocument(); + }); + + it("shows Unlimited budget and All Proxy Models when unset", () => { + render( + , + ); + + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + // Budget shows a standalone "Unlimited"; the limits fall back inline. + expect(screen.getByText("Unlimited")).toBeInTheDocument(); + expect(screen.getByText("TPM: Unlimited")).toBeInTheDocument(); + expect(screen.getByText("RPM: Unlimited")).toBeInTheDocument(); + }); + + it("renders loading skeletons instead of rows while loading", () => { + render( + , + ); + + expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); + expect(screen.queryByText("ShouldNotShow")).not.toBeInTheDocument(); + }); + + it("uses a search-aware empty state", () => { + const { rerender } = render(); + expect(screen.getByText("No organizations yet")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("No matching organizations")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx new file mode 100644 index 00000000000..8e68a57d2f7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { Building2, SearchX } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; +import { Organization } from "@/components/networking"; + +import { getOrganizationsTableColumns } from "./OrganizationsTableColumns"; + +interface OrganizationsTableProps { + organizations: Organization[]; + isLoading: boolean; + userRole: string; + searchActive: boolean; + onOrganizationClick: (organizationId: string) => void; + onEditClick: (organizationId: string) => void; + onDeleteClick: (organizationId: string) => void; +} + +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +function EmptyState({ searchActive }: { searchActive: boolean }) { + const Icon = searchActive ? SearchX : Building2; + return ( +
+
+ +
+
+ {searchActive ? "No matching organizations" : "No organizations yet"} +
+
+ {searchActive + ? "No organizations match your search. Try a different name or ID." + : "Create an organization to group teams, models, and budgets."} +
+
+ ); +} + +const OrganizationsTable: React.FC = ({ + organizations, + isLoading, + userRole, + searchActive, + onOrganizationClick, + onEditClick, + onDeleteClick, +}) => { + const [sorting, setSorting] = useState(DEFAULT_SORTING); + + const columns = useMemo(() => { + const deps = { userRole, onOrganizationClick, onEditClick, onDeleteClick }; + return getOrganizationsTableColumns(deps); + }, [userRole, onOrganizationClick, onEditClick, onDeleteClick]); + + return ( + organization.organization_id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading organizations…" + noDataMessage={} + size="compact" + /> + ); +}; + +export default OrganizationsTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx new file mode 100644 index 00000000000..31f6a00916c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTableColumns.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdentityCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells"; +import { Organization } from "@/components/networking"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; + +interface OrganizationBudget { + max_budget?: number | null; + tpm_limit?: number | null; + rpm_limit?: number | null; +} + +const getOrganizationBudget = (organization: Organization): OrganizationBudget => + (organization.litellm_budget_table ?? {}) as OrganizationBudget; + +function OrganizationLimitsCell({ organization }: { organization: Organization }) { + const { tpm_limit, rpm_limit } = getOrganizationBudget(organization); + return ( +
+ TPM: {tpm_limit ? tpm_limit : "Unlimited"} + RPM: {rpm_limit ? rpm_limit : "Unlimited"} +
+ ); +} + +interface OrganizationRowActionsProps { + organization: Organization; + onEditClick: (organizationId: string) => void; + onDeleteClick: (organizationId: string) => void; +} + +function OrganizationRowActions({ organization, onEditClick, onDeleteClick }: OrganizationRowActionsProps) { + return ( + + + + + + onEditClick(organization.organization_id)} + > + + Edit + + onDeleteClick(organization.organization_id)} + > + + Delete + + + + ); +} + +export interface OrganizationsTableColumnsDeps { + userRole: string; + onOrganizationClick: (organizationId: string) => void; + onEditClick: (organizationId: string) => void; + onDeleteClick: (organizationId: string) => void; +} + +export const getOrganizationsTableColumns = ({ + userRole, + onOrganizationClick, + onEditClick, + onDeleteClick, +}: OrganizationsTableColumnsDeps): ColumnDef[] => [ + { + id: "organization_id", + accessorKey: "organization_id", + meta: { title: "Organization ID" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + onOrganizationClick(row.original.organization_id)} + /> + ), + }, + { + id: "organization_alias", + accessorKey: "organization_alias", + meta: { title: "Organization Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + cell: ({ row }) => { + const alias = row.original.organization_alias; + return ( + + {alias || "-"} + + ); + }, + }, + { + id: "created_at", + accessorKey: "created_at", + sortingFn: "datetime", + meta: { title: "Created" }, + header: ({ column }) => , + size: 130, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "spend", + accessorKey: "spend", + meta: { title: "Spend (USD)" }, + header: ({ column }) => , + size: 120, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "max_budget", + meta: { title: "Budget (USD)" }, + header: "Budget (USD)", + size: 120, + enableSorting: false, + cell: ({ row }) => ( + + ), + }, + { + id: "models", + meta: { title: "Models", skeleton: "chips" }, + header: "Models", + size: 260, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "limits", + meta: { title: "TPM / RPM Limits" }, + header: "TPM / RPM Limits", + size: 150, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "members", + meta: { title: "Members" }, + header: "Members", + size: 100, + enableSorting: false, + cell: ({ row }) => {row.original.members?.length ?? 0} Members, + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => + userRole === "Admin" ? ( +
+ +
+ ) : null, + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.test.tsx deleted file mode 100644 index 75a6d30ac2e..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.test.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render } from "@testing-library/react"; -import React from "react"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({ - __esModule: true, - default: () => null, -})); -vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({ - __esModule: true, - default: () => null, -})); -vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => ({ - accessToken: null, - userId: null, - userRole: null, - }), -})); - -import OrganizationsTable from "./organizations"; - -const renderWithQueryClient = (ui: React.ReactElement) => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render({ui}); -}; - -describe("OrganizationsTable", () => { - it("should render the OrganizationsTable component", () => { - const { getByText } = renderWithQueryClient( - , - ); - - expect(getByText("+ Create New Organization")).toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx deleted file mode 100644 index 87d8010759d..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/organizations.tsx +++ /dev/null @@ -1,535 +0,0 @@ -import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; -import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels"; -import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { ChevronDownIcon, ChevronRightIcon, RefreshIcon } from "@heroicons/react/outline"; -import { - Badge, - Button, - Card, - Col, - Grid, - Icon, - Tab, - TabGroup, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - TabList, - TabPanel, - TabPanels, - Text, - TextInput, -} from "@tremor/react"; -import { Form, Input, Modal, Select as Select2, Tooltip } from "antd"; -import { useQueryClient } from "@tanstack/react-query"; -import React, { useState } from "react"; -import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; -import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; -import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; -import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; -import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; -import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; -import NotificationsManager from "@/components/molecules/notifications_manager"; -import { - Organization, - organizationCreateCall, - organizationDeleteCall, - organizationListCall, -} from "@/components/networking"; -import OrganizationInfoView from "@/components/organization/organization_view"; -import NumericalInput from "@/components/shared/numerical_input"; -import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; - -interface OrganizationsTableProps { - userRole: string; - accessToken: string | null; - lastRefreshed?: string; - handleRefreshClick?: () => void; - premiumUser: boolean; -} - -export const fetchOrganizations = async ( - accessToken: string, - setOrganizations: (organizations: Organization[]) => void, - org_id: string | null = null, - org_alias: string | null = null, -) => { - const organizations = await organizationListCall(accessToken, org_id, org_alias); - setOrganizations(organizations); -}; - -const OrganizationsTable: React.FC = ({ - userRole, - accessToken, - lastRefreshed, - handleRefreshClick, - premiumUser, -}) => { - const [selectedOrgId, setSelectedOrgId] = useState(null); - const [editOrg, setEditOrg] = useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [orgToDelete, setOrgToDelete] = useState(null); - const [isDeleting, setIsDeleting] = useState(false); - const [isOrgModalVisible, setIsOrgModalVisible] = useState(false); - const [form] = Form.useForm(); - const [expandedAccordions, setExpandedAccordions] = useState>({}); - const [showFilters, setShowFilters] = useState(false); - const [filters, setFilters] = useState({ - org_id: "", - org_alias: "", - sort_by: "created_at", - sort_order: "desc", - }); - - const queryClient = useQueryClient(); - const { data: organizations = [] } = useOrganizations({ org_id: filters.org_id, org_alias: filters.org_alias }); - const { data: userModels = [] } = useUserModels(); - - const refetchOrganizations = () => queryClient.invalidateQueries({ queryKey: organizationKeys.lists() }); - - const handleFilterChange = (key: keyof FilterState, value: string) => { - setFilters((previousFilters) => ({ ...previousFilters, [key]: value })); - }; - - const handleFilterReset = () => { - setFilters({ - org_id: "", - org_alias: "", - sort_by: "created_at", - sort_order: "desc", - }); - }; - - const handleDelete = (orgId: string | null) => { - if (!orgId) return; - - setOrgToDelete(orgId); - setIsDeleteModalOpen(true); - }; - - const confirmDelete = async () => { - if (!orgToDelete || !accessToken) return; - - try { - setIsDeleting(true); - await organizationDeleteCall(accessToken, orgToDelete); - NotificationsManager.success("Organization deleted successfully"); - - setIsDeleteModalOpen(false); - setOrgToDelete(null); - await refetchOrganizations(); - } catch (error) { - console.error("Error deleting organization:", error); - } finally { - setIsDeleting(false); - } - }; - - const cancelDelete = () => { - setIsDeleteModalOpen(false); - setOrgToDelete(null); - }; - - const handleCreate = async (values: any) => { - try { - if (!accessToken) return; - - // Transform allowed_vector_store_ids and allowed_mcp_servers_and_groups into object_permission - if ( - (values.allowed_vector_store_ids && values.allowed_vector_store_ids.length > 0) || - (values.allowed_mcp_servers_and_groups && - (values.allowed_mcp_servers_and_groups.servers?.length > 0 || - values.allowed_mcp_servers_and_groups.accessGroups?.length > 0)) - ) { - values.object_permission = {}; - if (values.allowed_vector_store_ids && values.allowed_vector_store_ids.length > 0) { - values.object_permission.vector_stores = values.allowed_vector_store_ids; - delete values.allowed_vector_store_ids; - } - if (values.allowed_mcp_servers_and_groups) { - if (values.allowed_mcp_servers_and_groups.servers?.length > 0) { - values.object_permission.mcp_servers = values.allowed_mcp_servers_and_groups.servers; - } - if (values.allowed_mcp_servers_and_groups.accessGroups?.length > 0) { - values.object_permission.mcp_access_groups = values.allowed_mcp_servers_and_groups.accessGroups; - } - delete values.allowed_mcp_servers_and_groups; - } - } - - await organizationCreateCall(accessToken, values); - NotificationsManager.success("Organization created successfully"); - setIsOrgModalVisible(false); - form.resetFields(); - await refetchOrganizations(); - } catch (error) { - console.error("Error creating organization:", error); - } - }; - - const handleCancel = () => { - setIsOrgModalVisible(false); - form.resetFields(); - }; - - if (!premiumUser) { - return ( -
- - This is a LiteLLM Enterprise feature, and requires a valid key to use. Get a trial key{" "} - - here - - . - -
- ); - } - - return ( -
- - - {(userRole === "Admin" || userRole === "Org Admin") && ( - - )} - {selectedOrgId ? ( - { - setSelectedOrgId(null); - setEditOrg(false); - }} - accessToken={accessToken} - is_org_admin={true} // You'll need to implement proper org admin check - is_proxy_admin={userRole === "Admin"} - userModels={userModels} - editOrg={editOrg} - /> - ) : ( - - -
- Your Organizations -
-
- {lastRefreshed && Last Refreshed: {lastRefreshed}} - -
-
- - - Click on “Organization ID” to view organization details. - - - -
-
- -
-
- - - - Organization ID - Organization Name - Created - Spend (USD) - Budget (USD) - Models - TPM / RPM Limits - Info - Actions - - - - - {organizations && organizations.length > 0 - ? organizations - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .map((org: Organization) => ( - - - - - {org.organization_alias} - - - - - - - - - - 3 ? "px-0" : ""} - > -
- {Array.isArray(org.models) ? ( -
- {org.models.length === 0 ? ( - - All Proxy Models - - ) : ( - <> -
- {org.models.length > 3 && ( -
- { - setExpandedAccordions((prev) => ({ - ...prev, - [org.organization_id || ""]: - !prev[org.organization_id || ""], - })); - }} - /> -
- )} -
- {org.models.slice(0, 3).map((model, index) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} - {org.models.length > 3 && - !expandedAccordions[org.organization_id || ""] && ( - - - +{org.models.length - 3}{" "} - {org.models.length - 3 === 1 - ? "more model" - : "more models"} - - - )} - {expandedAccordions[org.organization_id || ""] && ( -
- {org.models.slice(3).map((model, index) => - model === "all-proxy-models" ? ( - - All Proxy Models - - ) : ( - - - {model.length > 30 - ? `${getModelDisplayName(model).slice(0, 30)}...` - : getModelDisplayName(model)} - - - ), - )} -
- )} -
-
- - )} -
- ) : null} -
-
- - - TPM:{" "} - {org.litellm_budget_table?.tpm_limit - ? org.litellm_budget_table?.tpm_limit - : "Unlimited"} -
- RPM:{" "} - {org.litellm_budget_table?.rpm_limit - ? org.litellm_budget_table?.rpm_limit - : "Unlimited"} -
-
- - {org.members?.length || 0} Members - - - {userRole === "Admin" && ( - <> - { - setSelectedOrgId(org.organization_id); - setEditOrg(true); - }} - /> - handleDelete(org.organization_id)} - /> - - )} - -
- )) - : null} -
-
-
- -
-
-
-
- )} - -
- -
- - - - - form.setFieldValue("models", values)} - context="organization" - /> - - - - - - - - daily - weekly - monthly - - - - - - - - - - - Allowed Vector Stores{" "} - - - - - } - name="allowed_vector_store_ids" - className="mt-4" - help="Select vector stores this organization can access. Leave empty for access to all vector stores" - > - form.setFieldValue("allowed_vector_store_ids", values)} - value={form.getFieldValue("allowed_vector_store_ids")} - accessToken={accessToken || ""} - placeholder="Select vector stores (optional)" - /> - - - - Allowed MCP Servers{" "} - - - - - } - name="allowed_mcp_servers_and_groups" - className="mt-4" - help="Select MCP servers and access groups this organization can access." - > - form.setFieldValue("allowed_mcp_servers_and_groups", values)} - value={form.getFieldValue("allowed_mcp_servers_and_groups")} - accessToken={accessToken || ""} - placeholder="Select MCP servers and access groups (optional)" - /> - - - - - - -
- -
-
-
- - -
- ); -}; - -export default OrganizationsTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx index 649e54f63eb..a492a572580 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/page.tsx @@ -1,9 +1,9 @@ "use client"; -import OrganizationsTable from "./_components/organizations"; +import OrganizationsPanel from "./_components/OrganizationsPanel"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function OrganizationsPage() { const { accessToken, userRole, premiumUser } = useAuthorized(); - return ; + return ; }