Merge pull request #34081 from BerriAI/litellm_/gallant-kapitsa-a809e0

refactor(ui): migrate organizations table onto shared DataTable
This commit is contained in:
yuneng-jiang 2026-07-21 10:27:59 -07:00 committed by GitHub
commit 0b4851dd81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 807 additions and 585 deletions

View file

@ -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

View file

@ -7,8 +7,6 @@ describe("OrganizationFilters", () => {
const defaultFilters: FilterState = {
org_id: "",
org_alias: "",
sort_by: "",
sort_order: "asc",
};
it("should render", () => {

View file

@ -14,8 +14,6 @@ interface OrganizationFiltersProps {
type FilterState = {
org_id: string;
org_alias: string;
sort_by: string;
sort_order: "asc" | "desc";
};
const OrganizationFilters = ({

View file

@ -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 }) => (
<div data-testid="organizations-table">isLoading:{String(props.isLoading)}</div>
),
}));
import OrganizationsPanel from "./OrganizationsPanel";
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
describe("OrganizationsPanel", () => {
it("gates non-premium users behind the enterprise notice", () => {
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={false} />);
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(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
expect(screen.getByText("+ Create New Organization")).toBeInTheDocument();
});
it("resolves the loading skeleton to false when the query is disabled (no token)", () => {
renderWithQueryClient(<OrganizationsPanel userRole="Admin" accessToken={null} premiumUser={true} />);
// A disabled React Query keeps isPending true forever; feeding isLoading avoids a stuck skeleton.
expect(screen.getByTestId("organizations-table")).toHaveTextContent("isLoading:false");
});
});

View file

@ -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<OrganizationsPanelProps> = ({ userRole, accessToken, premiumUser }) => {
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [editOrg, setEditOrg] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [orgToDelete, setOrgToDelete] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isOrgModalVisible, setIsOrgModalVisible] = useState(false);
const [form] = Form.useForm();
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<FilterState>({ 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 (
<div className="mx-4 mt-4">
<p className="text-sm text-muted-foreground">
This is a LiteLLM Enterprise feature, and requires a valid key to use. Get a trial key{" "}
<a
href="https://www.litellm.ai/#pricing"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
here
</a>
.
</p>
</div>
);
}
return (
<div className="mx-4 mt-4 flex flex-col gap-4">
{(userRole === "Admin" || userRole === "Org Admin") && (
<Button className="w-fit" onClick={() => setIsOrgModalVisible(true)}>
+ Create New Organization
</Button>
)}
{selectedOrgId ? (
<OrganizationInfoView
organizationId={selectedOrgId}
onClose={() => {
setSelectedOrgId(null);
setEditOrg(false);
}}
accessToken={accessToken}
is_org_admin={true}
is_proxy_admin={userRole === "Admin"}
userModels={userModels}
editOrg={editOrg}
/>
) : (
<>
<p className="text-sm text-muted-foreground">Click on an organization ID to view its details.</p>
<OrganizationFilters
filters={filters}
showFilters={showFilters}
onToggleFilters={setShowFilters}
onChange={handleFilterChange}
onReset={handleFilterReset}
/>
<OrganizationsTable
organizations={organizations}
isLoading={isLoading}
userRole={userRole}
searchActive={searchActive}
onOrganizationClick={setSelectedOrgId}
onEditClick={(organizationId) => {
setSelectedOrgId(organizationId);
setEditOrg(true);
}}
onDeleteClick={handleDelete}
/>
</>
)}
<Modal title="Create Organization" visible={isOrgModalVisible} width={800} footer={null} onCancel={handleCancel}>
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
<Form.Item
label="Organization Name"
name="organization_alias"
rules={[
{
required: true,
message: "Please input an organization name",
},
]}
>
<Input placeholder="" />
</Form.Item>
<Form.Item label="Models" name="models">
<ModelSelect
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
value={form.getFieldValue("models")}
onChange={(values) => form.setFieldValue("models", values)}
context="organization"
/>
</Form.Item>
<Form.Item label="Max Budget (USD)" name="max_budget">
<NumericalInput step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item label="Reset Budget" name="budget_duration">
<Select2 defaultValue={null} placeholder="n/a">
<Select2.Option value="24h">daily</Select2.Option>
<Select2.Option value="7d">weekly</Select2.Option>
<Select2.Option value="30d">monthly</Select2.Option>
</Select2>
</Form.Item>
<Form.Item label="Tokens per minute Limit (TPM)" name="tpm_limit">
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item
label={
<span>
Allowed Vector Stores{" "}
<Tooltip title="Select which vector stores this organization can access by default. Leave empty for access to all vector stores">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="allowed_vector_store_ids"
className="mt-4"
help="Select vector stores this organization can access. Leave empty for access to all vector stores"
>
<VectorStoreSelector
onChange={(values) => form.setFieldValue("allowed_vector_store_ids", values)}
value={form.getFieldValue("allowed_vector_store_ids")}
accessToken={accessToken || ""}
placeholder="Select vector stores (optional)"
/>
</Form.Item>
<Form.Item
label={
<span>
Allowed MCP Servers{" "}
<Tooltip title="Select which MCP servers and access groups this organization can access by default.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="allowed_mcp_servers_and_groups"
className="mt-4"
help="Select MCP servers and access groups this organization can access."
>
<MCPServerSelector
onChange={(values) => 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)"
/>
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} />
</Form.Item>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="submit">Create Organization</Button>
</div>
</Form>
</Modal>
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Organization?"
message="Are you sure you want to delete this organization? This action cannot be undone."
resourceInformationTitle="Organization Information"
resourceInformation={[{ label: "Organization ID", value: orgToDelete, code: true }]}
onCancel={cancelDelete}
onOk={confirmDelete}
confirmLoading={isDeleting}
/>
</div>
);
};
export default OrganizationsPanel;

View file

@ -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 => ({
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(<OrganizationsTable {...baseProps} organizations={[]} />);
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(
<OrganizationsTable
{...baseProps}
onOrganizationClick={onOrganizationClick}
organizations={[makeOrganization({ organization_id: "org-123" })]}
/>,
);
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(
<OrganizationsTable
{...baseProps}
userRole="Admin"
onEditClick={onEditClick}
onDeleteClick={onDeleteClick}
organizations={[makeOrganization({ organization_id: "org-9" })]}
/>,
);
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(
<OrganizationsTable
{...baseProps}
userRole="Internal User"
organizations={[makeOrganization({ organization_id: "org-9" })]}
/>,
);
expect(screen.queryByTestId("organization-actions-org-9")).not.toBeInTheDocument();
});
it("sorts by created_at descending by default", () => {
render(
<OrganizationsTable
{...baseProps}
organizations={[
makeOrganization({
organization_id: "org-old",
organization_alias: "Older",
created_at: "2023-01-01T00:00:00Z",
}),
makeOrganization({
organization_id: "org-new",
organization_alias: "Newer",
created_at: "2024-06-01T00:00:00Z",
}),
]}
/>,
);
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(
<OrganizationsTable
{...baseProps}
organizations={[
makeOrganization({
litellm_budget_table: { max_budget: 100, tpm_limit: 1000, rpm_limit: 60 },
members: [{ user_id: "a" }, { user_id: "b" }, { user_id: "c" }],
models: ["gpt-4o", "claude-sonnet-4", "gemini-2.5-pro", "llama-3", "mistral-large"],
}),
]}
/>,
);
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(
<OrganizationsTable
{...baseProps}
organizations={[makeOrganization({ organization_id: "org-empty", litellm_budget_table: {}, models: [] })]}
/>,
);
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(
<OrganizationsTable
{...baseProps}
isLoading
organizations={[makeOrganization({ organization_alias: "ShouldNotShow" })]}
/>,
);
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
expect(screen.queryByText("ShouldNotShow")).not.toBeInTheDocument();
});
it("uses a search-aware empty state", () => {
const { rerender } = render(<OrganizationsTable {...baseProps} searchActive={false} organizations={[]} />);
expect(screen.getByText("No organizations yet")).toBeInTheDocument();
rerender(<OrganizationsTable {...baseProps} searchActive={true} organizations={[]} />);
expect(screen.getByText("No matching organizations")).toBeInTheDocument();
});
});

View file

@ -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 (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Icon className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">
{searchActive ? "No matching organizations" : "No organizations yet"}
</div>
<div className="text-sm text-muted-foreground">
{searchActive
? "No organizations match your search. Try a different name or ID."
: "Create an organization to group teams, models, and budgets."}
</div>
</div>
);
}
const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
organizations,
isLoading,
userRole,
searchActive,
onOrganizationClick,
onEditClick,
onDeleteClick,
}) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(() => {
const deps = { userRole, onOrganizationClick, onEditClick, onDeleteClick };
return getOrganizationsTableColumns(deps);
}, [userRole, onOrganizationClick, onEditClick, onDeleteClick]);
return (
<DataTable
data={organizations}
columns={columns}
getRowId={(organization, index) => organization.organization_id || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading organizations…"
noDataMessage={<EmptyState searchActive={searchActive} />}
size="compact"
/>
);
};
export default OrganizationsTable;

View file

@ -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 (
<div className="flex flex-col text-xs text-muted-foreground">
<span>TPM: {tpm_limit ? tpm_limit : "Unlimited"}</span>
<span>RPM: {rpm_limit ? rpm_limit : "Unlimited"}</span>
</div>
);
}
interface OrganizationRowActionsProps {
organization: Organization;
onEditClick: (organizationId: string) => void;
onDeleteClick: (organizationId: string) => void;
}
function OrganizationRowActions({ organization, onEditClick, onDeleteClick }: OrganizationRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open organization actions"
data-testid={`organization-actions-${organization.organization_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
data-testid="organization-action-edit"
onClick={() => onEditClick(organization.organization_id)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
data-testid="organization-action-delete"
onClick={() => onDeleteClick(organization.organization_id)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
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<Organization>[] => [
{
id: "organization_id",
accessorKey: "organization_id",
meta: { title: "Organization ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="Organization ID" />,
size: 220,
enableSorting: true,
cell: ({ row }) => (
<IdentityCell
title={row.original.organization_id}
titleClassName="font-mono text-xs font-normal"
className="max-w-56"
onClick={() => onOrganizationClick(row.original.organization_id)}
/>
),
},
{
id: "organization_alias",
accessorKey: "organization_alias",
meta: { title: "Organization Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Organization Name" />,
size: 200,
enableSorting: true,
cell: ({ row }) => {
const alias = row.original.organization_alias;
return (
<span className="block max-w-56 truncate text-sm font-medium" title={alias ?? undefined}>
{alias || "-"}
</span>
);
},
},
{
id: "created_at",
accessorKey: "created_at",
sortingFn: "datetime",
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
size: 130,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
id: "spend",
accessorKey: "spend",
meta: { title: "Spend (USD)" },
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
size: 120,
enableSorting: true,
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
},
{
id: "max_budget",
meta: { title: "Budget (USD)" },
header: "Budget (USD)",
size: 120,
enableSorting: false,
cell: ({ row }) => (
<MoneyCell value={getOrganizationBudget(row.original).max_budget} decimals={2} emptyText="Unlimited" showZero />
),
},
{
id: "models",
meta: { title: "Models", skeleton: "chips" },
header: "Models",
size: 260,
enableSorting: false,
cell: ({ row }) => <ModelsCell models={row.original.models} />,
},
{
id: "limits",
meta: { title: "TPM / RPM Limits" },
header: "TPM / RPM Limits",
size: 150,
enableSorting: false,
cell: ({ row }) => <OrganizationLimitsCell organization={row.original} />,
},
{
id: "members",
meta: { title: "Members" },
header: "Members",
size: 100,
enableSorting: false,
cell: ({ row }) => <span className="text-sm">{row.original.members?.length ?? 0} Members</span>,
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) =>
userRole === "Admin" ? (
<div className="flex justify-end">
<OrganizationRowActions organization={row.original} onEditClick={onEditClick} onDeleteClick={onDeleteClick} />
</div>
) : null,
},
];

View file

@ -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(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
describe("OrganizationsTable", () => {
it("should render the OrganizationsTable component", () => {
const { getByText } = renderWithQueryClient(
<OrganizationsTable userRole="Admin" accessToken={null} premiumUser={true} />,
);
expect(getByText("+ Create New Organization")).toBeInTheDocument();
});
});

View file

@ -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<OrganizationsTableProps> = ({
userRole,
accessToken,
lastRefreshed,
handleRefreshClick,
premiumUser,
}) => {
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [editOrg, setEditOrg] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [orgToDelete, setOrgToDelete] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isOrgModalVisible, setIsOrgModalVisible] = useState(false);
const [form] = Form.useForm();
const [expandedAccordions, setExpandedAccordions] = useState<Record<string, boolean>>({});
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<FilterState>({
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 (
<div>
<Text>
This is a LiteLLM Enterprise feature, and requires a valid key to use. Get a trial key{" "}
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer">
here
</a>
.
</Text>
</div>
);
}
return (
<div className="mx-4 h-[75vh]">
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
<Col numColSpan={1} className="flex flex-col gap-2">
{(userRole === "Admin" || userRole === "Org Admin") && (
<Button className="w-fit" onClick={() => setIsOrgModalVisible(true)}>
+ Create New Organization
</Button>
)}
{selectedOrgId ? (
<OrganizationInfoView
organizationId={selectedOrgId}
onClose={() => {
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}
/>
) : (
<TabGroup className="gap-2 h-[75vh] w-full">
<TabList className="flex justify-between mt-2 w-full items-center">
<div className="flex">
<Tab>Your Organizations</Tab>
</div>
<div className="flex items-center space-x-2">
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
<Icon
icon={RefreshIcon}
variant="shadow"
size="xs"
className="self-center"
onClick={handleRefreshClick}
/>
</div>
</TabList>
<TabPanels>
<TabPanel>
<Text>Click on &ldquo;Organization ID&rdquo; to view organization details.</Text>
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
<Col numColSpan={1}>
<Card className="w-full mx-auto flex-auto overflow-hidden overflow-y-auto max-h-[50vh]">
<div className="border-b px-6 py-4">
<div className="flex flex-col space-y-4">
<OrganizationFilters
filters={filters}
showFilters={showFilters}
onToggleFilters={setShowFilters}
onChange={handleFilterChange}
onReset={handleFilterReset}
/>
</div>
</div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Organization ID</TableHeaderCell>
<TableHeaderCell>Organization Name</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Spend (USD)</TableHeaderCell>
<TableHeaderCell>Budget (USD)</TableHeaderCell>
<TableHeaderCell>Models</TableHeaderCell>
<TableHeaderCell>TPM / RPM Limits</TableHeaderCell>
<TableHeaderCell>Info</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{organizations && organizations.length > 0
? organizations
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((org: Organization) => (
<TableRow key={org.organization_id}>
<TableCell>
<IdCell value={org.organization_id} onClick={setSelectedOrgId} />
</TableCell>
<TableCell>{org.organization_alias}</TableCell>
<TableCell>
<DateCell value={org.created_at} precision="date" />
</TableCell>
<TableCell>
<MoneyCell value={org.spend} decimals={4} />
</TableCell>
<TableCell>
<MoneyCell
value={org.litellm_budget_table?.max_budget}
decimals={2}
emptyText="Unlimited"
showZero
/>
</TableCell>
<TableCell
style={{
maxWidth: "8-x",
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
className={org.models.length > 3 ? "px-0" : ""}
>
<div className="flex flex-col">
{Array.isArray(org.models) ? (
<div className="flex flex-col">
{org.models.length === 0 ? (
<Badge size={"xs"} className="mb-1" color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<>
<div className="flex items-start">
{org.models.length > 3 && (
<div>
<Icon
icon={
expandedAccordions[org.organization_id || ""]
? ChevronDownIcon
: ChevronRightIcon
}
className="cursor-pointer"
size="xs"
onClick={() => {
setExpandedAccordions((prev) => ({
...prev,
[org.organization_id || ""]:
!prev[org.organization_id || ""],
}));
}}
/>
</div>
)}
<div className="flex flex-wrap gap-1">
{org.models.slice(0, 3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index} size={"xs"} color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<Badge key={index} size={"xs"} color="blue">
<Text>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Text>
</Badge>
),
)}
{org.models.length > 3 &&
!expandedAccordions[org.organization_id || ""] && (
<Badge size={"xs"} color="gray" className="cursor-pointer">
<Text>
+{org.models.length - 3}{" "}
{org.models.length - 3 === 1
? "more model"
: "more models"}
</Text>
</Badge>
)}
{expandedAccordions[org.organization_id || ""] && (
<div className="flex flex-wrap gap-1">
{org.models.slice(3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index + 3} size={"xs"} color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
<Badge key={index + 3} size={"xs"} color="blue">
<Text>
{model.length > 30
? `${getModelDisplayName(model).slice(0, 30)}...`
: getModelDisplayName(model)}
</Text>
</Badge>
),
)}
</div>
)}
</div>
</div>
</>
)}
</div>
) : null}
</div>
</TableCell>
<TableCell>
<Text>
TPM:{" "}
{org.litellm_budget_table?.tpm_limit
? org.litellm_budget_table?.tpm_limit
: "Unlimited"}
<br />
RPM:{" "}
{org.litellm_budget_table?.rpm_limit
? org.litellm_budget_table?.rpm_limit
: "Unlimited"}
</Text>
</TableCell>
<TableCell>
<Text>{org.members?.length || 0} Members</Text>
</TableCell>
<TableCell>
{userRole === "Admin" && (
<>
<TableIconActionButton
variant="Edit"
tooltipText="Edit organization"
onClick={() => {
setSelectedOrgId(org.organization_id);
setEditOrg(true);
}}
/>
<TableIconActionButton
variant="Delete"
tooltipText="Delete organization"
onClick={() => handleDelete(org.organization_id)}
/>
</>
)}
</TableCell>
</TableRow>
))
: null}
</TableBody>
</Table>
</Card>
</Col>
</Grid>
</TabPanel>
</TabPanels>
</TabGroup>
)}
</Col>
</Grid>
<Modal title="Create Organization" visible={isOrgModalVisible} width={800} footer={null} onCancel={handleCancel}>
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
<Form.Item
label="Organization Name"
name="organization_alias"
rules={[
{
required: true,
message: "Please input an organization name",
},
]}
>
<TextInput placeholder="" />
</Form.Item>
<Form.Item label="Models" name="models">
<ModelSelect
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
value={form.getFieldValue("models")}
onChange={(values) => form.setFieldValue("models", values)}
context="organization"
/>
</Form.Item>
<Form.Item label="Max Budget (USD)" name="max_budget">
<NumericalInput step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item label="Reset Budget" name="budget_duration">
<Select2 defaultValue={null} placeholder="n/a">
<Select2.Option value="24h">daily</Select2.Option>
<Select2.Option value="7d">weekly</Select2.Option>
<Select2.Option value="30d">monthly</Select2.Option>
</Select2>
</Form.Item>
<Form.Item label="Tokens per minute Limit (TPM)" name="tpm_limit">
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item label="Requests per minute Limit (RPM)" name="rpm_limit">
<NumericalInput step={1} width={400} />
</Form.Item>
<Form.Item
label={
<span>
Allowed Vector Stores{" "}
<Tooltip title="Select which vector stores this organization can access by default. Leave empty for access to all vector stores">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="allowed_vector_store_ids"
className="mt-4"
help="Select vector stores this organization can access. Leave empty for access to all vector stores"
>
<VectorStoreSelector
onChange={(values) => form.setFieldValue("allowed_vector_store_ids", values)}
value={form.getFieldValue("allowed_vector_store_ids")}
accessToken={accessToken || ""}
placeholder="Select vector stores (optional)"
/>
</Form.Item>
<Form.Item
label={
<span>
Allowed MCP Servers{" "}
<Tooltip title="Select which MCP servers and access groups this organization can access by default.">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="allowed_mcp_servers_and_groups"
className="mt-4"
help="Select MCP servers and access groups this organization can access."
>
<MCPServerSelector
onChange={(values) => 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)"
/>
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} />
</Form.Item>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="submit">Create Organization</Button>
</div>
</Form>
</Modal>
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Organization?"
message="Are you sure you want to delete this organization? This action cannot be undone."
resourceInformationTitle="Organization Information"
resourceInformation={[{ label: "Organization ID", value: orgToDelete, code: true }]}
onCancel={cancelDelete}
onOk={confirmDelete}
confirmLoading={isDeleting}
/>
</div>
);
};
export default OrganizationsTable;

View file

@ -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 <OrganizationsTable userRole={userRole ?? ""} accessToken={accessToken} premiumUser={premiumUser ?? false} />;
return <OrganizationsPanel userRole={userRole ?? ""} accessToken={accessToken} premiumUser={premiumUser ?? false} />;
}